diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 97506464..04b81446 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -14,7 +14,7 @@
- [ ] Tests added or updated (Unit and/or Integration as appropriate)
- [ ] `composer quality-check` passes locally (PHPCS, PHPStan and the tests)
-- [ ] Public API changes are documented in the affected package's `README.md`
+- [ ] Changelog fragment added (`composer changelog:add`)
- [ ] Breaking changes are explicitly justified below
## Breaking changes / migration notes
diff --git a/AGENTS.md b/AGENTS.md
index 68b7fca6..20bdf642 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,6 +1,6 @@
# wordpress-framework
-A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages/
/` to its read-only `wp-framework-` mirror on every push to `trunk`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses.
+A WordPress plugin framework published as five Composer packages under `ahegyes/`, PHP namespace `DeepWebSolutions\Framework\`, GPL-3.0-or-later. splitsh splits each `packages//` to its read-only `wp-framework-` mirror on every push to `trunk` that changes `packages/`, and a `v*` tag to every mirror without `--force`; consumers install the mirrors through `vcs` repositories, not Packagist, and prefix them with php-scoper through wordpress-configs. The packages form a stack: core requires bootstrap and shared, settings requires core, and woocommerce requires settings, each requiring only the layer below it, while shared also installs alone; a plugin ships only the closure of the highest package it uses.
## Layout
@@ -14,7 +14,7 @@ wordpress-framework/
├── packages/shared/ ahegyes/wp-framework-shared `require.php` · no package require · WordPress-free
│ └── src/ psr-4 DeepWebSolutions\Framework\Shared\ · Error/, Exception/, Result/, ValueObject/, Versioning/
├── packages/core/ ahegyes/wp-framework-core `require.php`, ext-filter · bootstrap ^2.0, shared ^2.0, psr/clock ^1, psr/container ^2.0.2, psr/log ^3.0.2, psr/simple-cache ^3
-│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot, ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface
+│ └── src/ psr-4 DeepWebSolutions\Framework\Core\ · PluginKernel, PluginBoot (is_development_environment (@internal)), ComponentInterface, CompositeComponentInterface, ConditionalComponentInterface
│ ├── AdminNotice/{AdminNotices, Notice, NoticeType, NoticeScope}
│ ├── Caching/{TransientCache, ObjectCache, CacheEntries (@internal), Exceptions/InvalidCacheKeyException}
│ ├── Clock/SystemClock · Logging/{AbstractLogger, ErrorLogLogger, GuardedLogger (@internal)}
@@ -34,7 +34,7 @@ wordpress-framework/
│ ├── ProductData/{ProductDataTab, ProductDataTabDefinitionInterface, ProductMeta}
│ ├── Setting/{SettingsTab, SettingsSection, SectionFieldsDefinitionInterface, Fields (@internal)}
│ └── Uninstall/MetaTypes
-├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, package-manifests, consumer-a, consumer-b, personas, end-to-end}}
+├── tests/{Unit, PHPStan, Integration, Php74, EndToEnd, Support, bin, Fixtures/{Core, Settings, Shared, WooCommerce, PackageManifests, consumer-a, consumer-b, personas, end-to-end}}
│ consumer-a: a WooCommerce plugin on all five packages, scoped through configs by its own composer.json
│ consumer-b: a plugin on bootstrap, shared, core and settings, without WooCommerce, scoped the same way
│ personas/{bootstrap, shared, core, settings, woocommerce}: one plugin per stack, shared a plain PHP project, each requiring only its top package, scoped the same way; each lists its stack's mirrors as `vcs` repositories after the path repository, the set its package README copies
@@ -78,13 +78,13 @@ actionlint && zizmor .github/
1. **Gates and toggles.** `should_load()` is static, pure and total: no hooks, writes, notices, translation, user reads or construction. Read options with an explicit default. Detect companions by include-time constants, classes or functions, aware of network activation. Gates read no request-type facts, because `REST_REQUEST` is defined only on `parse_request` and `is_admin()` holds in admin-ajax requests; callbacks check the request type. A feature's toggle and settings UI live outside its gate; a gate mixing an environment check with a setting exposes the environment half as a static method.
2. **Nodes and the container.** Node classes never extend, implement or `use` optional or late-loaded types. Never constructor-inject a gated component; inject ungated services. Every injected service that implements `ComponentInterface` is also a node, because only nodes run `register_hooks()`. Definitions are static closures, taking `ContainerInterface $c` only when they read the container, or autowiring, never PHP-DI helpers. Hold the built container as `ContainerInterface`. Container reads belong in the kernel and composition wiring: a class-entry read checks the entry's type before use, and a factory collecting contributors re-applies their `should_load()` before resolving them. Framework classes take their logger injected; the composition root chooses the logger. A failure is logged with its Throwable under `exception`, a `WP_Error`'s Throwable data included. The uninstall engine runs without a composition root, so it writes the jobs it could not unschedule to PHP's error log, one line per site.
3. **Constructors and `register_hooks()`.** Constructors take services only. Plugins boot on `plugins_loaded` at priority 15, after `woocommerce_loaded`, Action Scheduler's priorities 0 and 1 and `woocommerce_blocks_loaded` have fired, so work that waits for them hooks `init` or later. `register_hooks()` adds `array( $this, 'method' )` hooks, or constructs an `OptionsPage`, `ProductDataTab` or `ObjectForm` and calls its `register_hooks()`. It never fires actions, applies the plugin's own filters or reads a registry filled through them, because nodes later in the walk have not registered yet. Registration-time hooks register unconditionally and authorize in the callback. Scheduled-hook callbacks register on every request, because Action Scheduler runs actions in admin-ajax, WP-Cron and WP-CLI requests, and tolerate a duplicate or a missing invocation.
-4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the plugin controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
+4. **Packages and `Shared\`.** Package edges stay as in the file map: a package requires only the layer below it, declaring a lower one only to raise its floor, and builds on everything beneath it, as woocommerce also builds on WooCommerce; a new edge needs the owner. Settings and WC helpers take data and a definition object, hosts take data, and neither implements a core type, because the kernel builds nodes by class-string. Every reachable `Shared\` symbol is API, and WordPress never enters the shared package. Shared holds kernel types that code without core can use; a primitive several packages need lives in the lowest of them, public only when it fills a gap that PHP, its extensions and PHPStan's typing leave, with its siblings decided together. A kernel type enters only when it would work unchanged in any PHP project, and on the owner's ruling. Result serves only an internal operation with two or more expected failure reasons a caller branches on, declared as `AbstractResult` under `#[\NoDiscard]`; WordPress and WooCommerce APIs, filters, REST and AJAX responses and v1 public functions use `X|WP_Error`. Carriers are `final readonly`; one whose identity is its values extends `AbstractValueObject`, while a snapshot of stored data does not. `Version` parses only versions the project controls, never on the boot path. Framework code throws the `Shared\Exception` mirrors. A runtime check stays where a violation fails silently, can lose or expose data, or breaks a standard the class implements. A violation the platform tolerates harmlessly gets no runtime check, at most a PHPStan type.
5. **Safeguards.** Everything a safeguard calls lives in `includes/safeguards.php` or files it requires. The entry loads that file before `load.php` and the floor gate, so a safeguard runs on any runtime that parses the plugin: it parses on PHP 7.4 and calls only WordPress APIs present at the oldest WordPress the bootstrap package runs on, which `phpstan.bootstrap.neon` sets, or guards them. Relaxation happens only through the safeguard's own filter, after `{prefix}/boot/succeeded`, on an explicit `false`.
6. **Shared logic.** `src/` holds a plugin's composition root and its components. Code that registers no hooks and is not part of the tree, such as option keys and readers, the footprint, migration steps and asset registrars, is prefixed functions and constants in `includes/`, which `functions.php` loads. Per-request state lives in a static holder or `WC()->session`. No service locator.
7. **Migration steps.** Append-only; a retired step keeps its code or throws. Converge from any partial state; verify writes and throw on failure, including a scheduler write that returns `WP_Error`. Role capabilities are granted by `Permission\CapabilityGrants`, never by a step. Create resources resumably (lookup marker, explicit `post_author`). Use WC CRUD for orders and products. A plain step finishes well under 10 minutes; bulk work is a `BatchedMigrationStepInterface` step, run only from the runner's scheduled hook, at about 1–2 s per batch. Steps run on `wp_loaded`, so every `init` registration exists. One linear list serves every build variant.
8. **Settings.** Full keys are the plugin's prefixed option constants; WC ids are flat. Boot-time defaults are constants; translated defaults come from the plugin's text reader after `init`. Validate on write, and guard every typed read: `filter_var( $raw, FILTER_VALIDATE_*, FILTER_NULL_ON_FAILURE ) ?? $default`, or a strict `in_array()` for a choice. Direct meta writes go through `wp_slash()`. The framework detects a failed query only where it runs the query itself; a WordPress or Action Scheduler API's failure is accepted as the API reports it. The uninstall engine is the exception, because it keeps consent until a pass runs clean. Framework components store data only under the consumer's `{prefix}_`, so the plugin's footprint covers it; a `TransientCache` prefix and any generation option it names start with it, so the footprint covers the generation option, and its entries, transients that all carry a timeout, expire on their own. An `ObjectCache` group starts with it too, which keeps it apart from core's and other plugins' groups. The bootstrap updater's release caches carry the text domain instead and expire on their own. WooCommerce multiselect option keys are truthy, because WooCommerce's save drops `'0'` and `''`.
9. **Object forms** render through `Forms::render_control()`, print their nonce bound to the object and verify it before they evaluate their definition; every framework saver goes through `Forms::sanitize_fields()` and leaves absent (unrendered or disabled) fields untouched, but for limitation 4's WooCommerce checkboxes and multiselects.
-10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/` (its file checks run only at release).
+10. **Translation, by-path files, Plugin Check.** Never translate at include or registration time. Framework strings use only the `'wp-framework'` domain, owned by core, settings and woocommerce; bootstrap and shared ship none, so a boot notice builds its translated message in its render callback and prints it through `Requirements::print_admin_notice()`. The bootstrap package stays 7.4-parseable, loaded by path, outside every `autoload` key, and defines code only as final classes with public static entry points; consumers require only its `load.php`, whose path is public API. Code is Plugin-Check-clean, using inline `phpcs:ignore` with a reason; local PHPCS runs the full `WordPress` standard, which covers the WordPress sniffs Plugin Check runs, and Plugin Check's own `PluginCheck` sniffs over the packages, which Plugin Check skips in a plugin's `vendor-prefixed/`.
## Contract
@@ -96,7 +96,7 @@ Releases follow SemVer on this contract from the first tag: a break of it ships
## Style
-- Every PHP file opens with ``, which the pull request commits. CI validates the fragments with `composer changelog:validate`.
+
## Filing changes
- Keep one concern per pull request.
- Fill in the template's breaking-changes section.
- Follow the code, documentation and commit conventions in [AGENTS.md](AGENTS.md).
+
+## Releases
+
+The five packages release together under one version. A release writes the fragments into a new version block of `CHANGELOG.md`, whose version the fragments' significance sets:
+
+```sh
+composer changelog:write
+```
+
+Pushing the tag `v` on that commit splits it to every mirror, where consumers' `vcs` repositories find the release.
+
+## WooCommerce floor
+
+The woocommerce package tracks the newest stable WooCommerce release until a tag. Raising the floor moves, in one commit, the manifest's `extra.requires-wc`, the root's and the fixtures' `wp-plugin/woocommerce` and `php-stubs/woocommerce-stubs` constraints, the fixtures' `WC requires at least` and `WC tested up to` headers, and the locks. The same commit re-checks that WooCommerce still uses each cache group `MetaTypes::DEFINITIONS` names, because the uninstall engine flushes them.
diff --git a/README.md b/README.md
index 08356715..e025a982 100644
--- a/README.md
+++ b/README.md
@@ -26,9 +26,7 @@ The packages form a stack: core builds on bootstrap and shared, settings on core
## Local development
```sh
-composer packages-install # PHP deps (wraps composer install, ignoring only the PHP upper bound)
-npm install # Node deps (wp-env)
-npm run wp-env:start # developer environment at http://localhost:8800, as local, with WooCommerce
+npm run setup # PHP and Node deps, then the developer environment at http://localhost:8800, as local, with WooCommerce
composer quality-check # Validation, PHPCS, PHPStan, Unit and Integration tests (starts the tests environment)
```
diff --git a/SECURITY.md b/SECURITY.md
index 761fac83..cdec1ee6 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -2,9 +2,7 @@
## Reporting a vulnerability
-Please report security vulnerabilities privately via [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new). Do not open a public issue.
-
-You can expect an initial response within 7 days. Once the report is triaged, you'll receive updates as fixes land. Once a fix is released, credit is given in the advisory unless you request anonymity.
+Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-framework/security/advisories/new), never in a public issue. The first response comes within 7 days, and the advisory credits the reporter once a fix is released, unless they ask to stay anonymous.
## Supported versions
diff --git a/composer.lock b/composer.lock
index 8f22bc70..8e4cbc5d 100644
--- a/composer.lock
+++ b/composer.lock
@@ -2566,12 +2566,12 @@
"source": {
"type": "git",
"url": "https://github.com/Roave/SecurityAdvisories.git",
- "reference": "a09945ae024a4cb2be97d080558455b979e3f743"
+ "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a09945ae024a4cb2be97d080558455b979e3f743",
- "reference": "a09945ae024a4cb2be97d080558455b979e3f743",
+ "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/fe3a585588a168d9f2727fb468145f4c5f56f5b4",
+ "reference": "fe3a585588a168d9f2727fb468145f4c5f56f5b4",
"shasum": ""
},
"conflict": {
@@ -2872,7 +2872,7 @@
"filament/forms": ">=3,<=3.3.52",
"filament/infolists": ">=3,<3.2.115|>=4,<=4.11.4|>=5,<=5.6.4",
"filament/tables": ">=3,<=3.3.50|>=4,<=4.11.4|>=5,<=5.6.4",
- "filegator/filegator": "<7.8",
+ "filegator/filegator": "<=7.14.4",
"filp/whoops": "<2.1.13",
"fineuploader/php-traditional-server": "<=1.2.2",
"firebase/php-jwt": "<7",
@@ -2950,6 +2950,7 @@
"hillelcoren/invoice-ninja": "<5.3.35",
"himiklab/yii2-jqgrid-widget": "<1.0.8",
"hjue/justwriting": "<=1",
+ "hms5232/laravel-swagger": "<0.2.1",
"hov/jobfair": "<1.0.13|>=2,<2.0.2",
"httpsoft/http-message": "<1.0.12",
"hybridauth/hybridauth": "<=3.12.2",
@@ -3733,8 +3734,8 @@
"type": "tidelift"
}
],
- "time": "2026-11-07T01:38:18+00:00",
- "published-time": "2026-11-07T01:38:22+00:00"
+ "time": "2026-11-10T03:11:40+00:00",
+ "published-time": "2026-11-10T03:11:44+00:00"
},
{
"name": "sebastian/cli-parser",
diff --git a/package-lock.json b/package-lock.json
index 220ebecd..1ff8ad7a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -95,6 +95,16 @@
"url": "https://opencollective.com/babel"
}
},
+ "node_modules/@babel/core/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/generator": {
"version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
@@ -142,6 +152,16 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/helper-create-class-features-plugin": {
"version": "7.29.12",
"resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.12.tgz",
@@ -164,6 +184,16 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/helper-create-regexp-features-plugin": {
"version": "7.29.10",
"resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.10.tgz",
@@ -182,6 +212,16 @@
"@babel/core": "^7.0.0"
}
},
+ "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/helper-define-polyfill-provider": {
"version": "0.6.8",
"resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
@@ -1426,6 +1466,16 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/@babel/plugin-transform-runtime/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/plugin-transform-shorthand-properties": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
@@ -1680,6 +1730,16 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/preset-env/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/@babel/preset-modules": {
"version": "0.1.6-no-external-plugins",
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
@@ -5992,6 +6052,63 @@
"url": "https://github.com/sponsors/gregberge"
}
},
+ "node_modules/@svgr/core/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/@svgr/core/node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@svgr/core/node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/@svgr/hast-util-to-babel-ast": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz",
@@ -6055,6 +6172,63 @@
"@svgr/core": "*"
}
},
+ "node_modules/@svgr/plugin-svgo/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/@svgr/plugin-svgo/node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@svgr/plugin-svgo/node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/@svgr/webpack": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/@svgr/webpack/-/webpack-8.1.0.tgz",
@@ -6613,19 +6787,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/@typescript-eslint/utils": {
"version": "8.72.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.1.tgz",
@@ -7492,33 +7653,6 @@
}
}
},
- "node_modules/@wordpress/eslint-plugin/node_modules/cosmiconfig": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
- "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/parse-json": "^4.0.0",
- "import-fresh": "^3.2.1",
- "parse-json": "^5.0.0",
- "path-type": "^4.0.0",
- "yaml": "^1.10.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/@wordpress/eslint-plugin/node_modules/yaml": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
- "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/@wordpress/hooks": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz",
@@ -8848,6 +8982,16 @@
"@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
}
},
+ "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/babel-plugin-polyfill-corejs3": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
@@ -8907,9 +9051,9 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
- "version": "2.11.34",
- "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
- "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
+ "version": "2.11.35",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.35.tgz",
+ "integrity": "sha512-GNk8KX52CHqeNKGk8+7tZnQab/2k1t/BgrUHPKNdijzpEhF1saPQZHVKb6m5loSAxB39gyrTYw34Qf9CQ3u1qQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
@@ -9516,6 +9660,16 @@
"node": ">=8"
}
},
+ "node_modules/check-node-version/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -10049,60 +10203,20 @@
}
},
"node_modules/cosmiconfig": {
- "version": "8.3.6",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
- "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
+ "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "import-fresh": "^3.3.0",
- "js-yaml": "^4.1.0",
- "parse-json": "^5.2.0",
- "path-type": "^4.0.0"
+ "@types/parse-json": "^4.0.0",
+ "import-fresh": "^3.2.1",
+ "parse-json": "^5.0.0",
+ "path-type": "^4.0.0",
+ "yaml": "^1.10.0"
},
"engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/d-fischer"
- },
- "peerDependencies": {
- "typescript": ">=4.9.5"
- },
- "peerDependenciesMeta": {
- "typescript": {
- "optional": true
- }
- }
- },
- "node_modules/cosmiconfig/node_modules/argparse": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
- "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
- "dev": true,
- "license": "Python-2.0"
- },
- "node_modules/cosmiconfig/node_modules/js-yaml": {
- "version": "4.3.2",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
- "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
- "dev": true,
- "funding": [
- {
- "type": "github",
- "url": "https://github.com/sponsors/puzrin"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/nodeca"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "argparse": "^2.0.1"
- },
- "bin": {
- "js-yaml": "bin/js-yaml.js"
+ "node": ">=10"
}
},
"node_modules/crc-32": {
@@ -10199,19 +10313,6 @@
}
}
},
- "node_modules/css-loader/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/css-select": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
@@ -10802,6 +10903,22 @@
"node": ">= 6.0.0"
}
},
+ "node_modules/docker-compose/node_modules/yaml": {
+ "version": "2.9.1",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
+ "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -10961,9 +11078,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
- "version": "1.5.472",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
- "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
+ "version": "1.5.473",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.473.tgz",
+ "integrity": "sha512-9njcRQK+6FOXNAWw5g+h2RPiyazqo5RFMVJ39ZMHjDHUNnnFlhnOGV7O0WmJetODDx19ES5p+FLi+KCVFQGugw==",
"dev": true,
"license": "ISC"
},
@@ -11022,9 +11139,9 @@
}
},
"node_modules/enhanced-resolve": {
- "version": "5.26.0",
- "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz",
- "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==",
+ "version": "5.26.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.1.tgz",
+ "integrity": "sha512-PPnnSJq3azKOIewXR/uppBV/6h98dprBJ/WaQ9Qgj10xX1lJLNfg+XShpbsrSqcDSy2yYYyhfXM1wWhftqgyzw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -11628,6 +11745,16 @@
"node": "*"
}
},
+ "node_modules/eslint-plugin-import/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/eslint-plugin-jest": {
"version": "29.17.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.17.0.tgz",
@@ -11738,19 +11865,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint-plugin-jsdoc/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/eslint-plugin-jsx-a11y": {
"version": "6.10.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz",
@@ -11980,6 +12094,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/eslint-plugin-react/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/eslint-scope": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
@@ -13853,19 +13977,6 @@
"semver": "^7.7.1"
}
},
- "node_modules/is-bun-module/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
@@ -14346,9 +14457,9 @@
}
},
"node_modules/is-unsafe": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
- "integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.3.tgz",
+ "integrity": "sha512-ZBllczCCO267vgdFb6Tq3pv+LhHaCiGMdr+ZsuL+OopruAg7S+3ZDs1vD0RPC0uSrkjzbraxaGjtX1YBxjPeFQ==",
"dev": true,
"funding": [
{
@@ -14697,19 +14808,6 @@
"npm": ">=6"
}
},
- "node_modules/jsonwebtoken/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -16469,9 +16567,9 @@
}
},
"node_modules/napi-postinstall": {
- "version": "0.3.7",
- "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.7.tgz",
- "integrity": "sha512-jyU4cU4kVAmy/3qjP+DcoQTNXfINHc+dFw54gGqWC8nu0mpV0kOnk7kff+J+Q18SbX0k3Np8Rm7yFjd3phatNQ==",
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.8.tgz",
+ "integrity": "sha512-dHbDC5V1g/EI8OLEtL4Iuwbu0fgzmyykxenn2KnlsX9XdhTXL3gWnfbbP+ab+oUcgV6CFpb8QGyQt/ZXA0UXig==",
"dev": true,
"license": "MIT",
"bin": {
@@ -16547,6 +16645,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/node-exports-info/node_modules/semver": {
+ "version": "6.3.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.2.tgz",
+ "integrity": "sha512-GYrCADwpu6yV5OAvchkQ27Z5wwMxWAEMg5NNWjyHh/5DboFFqbdbvYNIvZouQpxXyeMPoTOh0vdPYKlf0ZswdQ==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.63",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
@@ -16573,19 +16681,6 @@
"node": ">=10"
}
},
- "node_modules/normalize-package-data/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -16676,6 +16771,13 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
+ "node_modules/npm-package-json-lint/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
"node_modules/npm-package-json-lint/node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -16686,6 +16788,33 @@
"node": ">=8"
}
},
+ "node_modules/npm-package-json-lint/node_modules/cosmiconfig": {
+ "version": "8.3.6",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
+ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0",
+ "path-type": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/npm-package-json-lint/node_modules/globby": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
@@ -16707,6 +16836,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/npm-package-json-lint/node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
"node_modules/npm-package-json-lint/node_modules/json-schema-traverse": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -16714,19 +16866,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/npm-package-json-lint/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/npm-package-json-lint/node_modules/slash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
@@ -17977,46 +18116,6 @@
"webpack": "^5.0.0"
}
},
- "node_modules/postcss-loader/node_modules/cosmiconfig": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",
- "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@types/parse-json": "^4.0.0",
- "import-fresh": "^3.2.1",
- "parse-json": "^5.0.0",
- "path-type": "^4.0.0",
- "yaml": "^1.10.0"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/postcss-loader/node_modules/semver": {
- "version": "7.8.5",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
- "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- },
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/postcss-loader/node_modules/yaml": {
- "version": "1.10.3",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
- "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": ">= 6"
- }
- },
"node_modules/postcss-media-query-parser": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
@@ -19020,9 +19119,9 @@
}
},
"node_modules/read-pkg/node_modules/semver": {
- "version": "5.7.2",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
- "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.3.tgz",
+ "integrity": "sha512-kM18BPOqTi0+TB68703/GJRpAML5Me+IDQsR1J26YB3EFKKNEyHIVZuWDX7+WKQB3tHwDwgk24BfhdpWUbytvQ==",
"dev": true,
"license": "ISC",
"bin": {
@@ -19824,13 +19923,16 @@
}
},
"node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "version": "7.8.6",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
+ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
}
},
"node_modules/send": {
@@ -23352,19 +23454,13 @@
"license": "ISC"
},
"node_modules/yaml": {
- "version": "2.9.1",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
- "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
+ "version": "1.10.3",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
+ "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"dev": true,
"license": "ISC",
- "bin": {
- "yaml": "bin.mjs"
- },
"engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
+ "node": ">= 6"
}
},
"node_modules/yargs": {
diff --git a/packages/bootstrap/README.md b/packages/bootstrap/README.md
index d43eadc5..21b7ff8f 100644
--- a/packages/bootstrap/README.md
+++ b/packages/bootstrap/README.md
@@ -79,7 +79,15 @@ if ( ! Requirements::check_floors( __FILE__ ) ) {
require_once __DIR__ . '/functions.php';
```
-The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`, and the `dws_github_updater/config/{text-domain}` filter changes the repository, the asset or the channel, or turns updates off. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` ranks other suffixes above their release.
+The updater offers the plugin's GitHub releases through WordPress's own update screens while the plugin's `Update URI` header points at its github.com repository. It installs the release asset named after the plugin's text domain, such as `acme-persona-bootstrap.zip`. The `dws_github_updater/config/{text-domain}` filter receives and returns `enabled`, `repository` as `owner/name`, `asset` and `channel`: an installed version with a suffix follows the `prerelease` channel, the highest of the ten newest releases, and any other version the `stable` channel, the latest release. It offers only tags such as `v2.1.0` or `v2.1.0-rc.1`, whose optional suffix is `-dev`, `-alpha`, `-beta` or `-rc` in lowercase, optionally numbered, because PHP's `version_compare()` misorders other suffixes, such as `-pre`, `-patch` or `-Beta`.
+
+`Requirements::print_admin_notice()` prints a notice to users who can activate plugins, as `wp_admin_notice()` prints it, so a plugin can report a problem before it boots:
+
+```php
+function acme_pb_render_notice(): void {
+ Requirements::print_admin_notice( esc_html__( 'Acme Persona Bootstrap is running.', 'acme-persona-bootstrap' ), array( 'type' => 'info' ) );
+}
+```
## Contract and versioning
diff --git a/packages/bootstrap/load.php b/packages/bootstrap/load.php
index 1aae4203..358e4976 100644
--- a/packages/bootstrap/load.php
+++ b/packages/bootstrap/load.php
@@ -8,9 +8,7 @@
* @package DeepWebSolutions\Framework\Bootstrap
*/
-namespace DeepWebSolutions\Framework\Bootstrap;
-
-\defined( 'ABSPATH' ) || exit;
+defined( 'ABSPATH' ) || exit;
require_once __DIR__ . '/src/Requirements.php';
require_once __DIR__ . '/src/Updater.php';
diff --git a/packages/bootstrap/src/Requirements.php b/packages/bootstrap/src/Requirements.php
index 97b6223e..9b22377b 100644
--- a/packages/bootstrap/src/Requirements.php
+++ b/packages/bootstrap/src/Requirements.php
@@ -5,7 +5,7 @@
\defined( 'ABSPATH' ) || exit;
/**
- * Keeps a plugin from loading code its PHP or WordPress cannot run.
+ * Keeps a plugin from loading code its PHP or WordPress cannot run, and prints the notices that report it.
*
* @since 2.0.0
* @version 2.0.0
@@ -71,14 +71,15 @@ static function () use ( $plugin_file ): void {
/**
* Prints an admin notice about the plugin to users who can activate plugins, as wp_admin_notice() prints it.
*
- * The framework's boot checks report through it, because they run before the plugin's own admin notices exist or without them. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments.
+ * It needs nothing beyond WordPress itself, so it reports problems found before a plugin has booted. An error prints its message without a paragraph wrap unless the arguments set one, since WordPress core's requirement messages are already paragraphs. Before WordPress 6.4, which has no wp_admin_notice(), it prints the same markup and honors only the type and paragraph_wrap arguments.
*
* @since 2.0.0
* @version 2.0.0
*
+ * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args
+ *
* @param string|\WP_Error $message The message, as HTML, or the error whose message to print.
* @param array $args Optional. The notice's arguments, as wp_admin_notice() takes them. Default is an error notice.
- * @phpstan-param array{type?: string, dismissible?: bool, id?: string, additional_classes?: array, attributes?: array, paragraph_wrap?: bool} $args
*
* @return void
*/
diff --git a/packages/bootstrap/src/Updater.php b/packages/bootstrap/src/Updater.php
index 355f8558..7e557bbe 100644
--- a/packages/bootstrap/src/Updater.php
+++ b/packages/bootstrap/src/Updater.php
@@ -31,7 +31,7 @@ protected function __construct() {}
/**
* Offers the plugin's GitHub releases as updates, and answers its "View details" request, while its Update URI points at github.com.
*
- * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement. WordPress asks for Update URI offers from version 5.8, so below it no update is offered.
+ * The Update URI is read whenever WordPress asks, so the updater answers nothing once it points elsewhere. A release that needs a newer WordPress is not offered, because WordPress's automatic updates check only the PHP requirement.
*
* @since 2.0.0
* @version 2.0.0
@@ -49,9 +49,13 @@ static function ( $update, $plugin_data, $file ) use ( $plugin_file, $basename )
return $update;
}
- $config = self::get_updater_config( $plugin_file );
- $release = null === $config ? null : self::get_release( $config );
- if ( null === $config || null === $release || ! is_wp_version_compatible( $release['requires'] ) ) {
+ $config = self::get_updater_config( $plugin_file );
+ if ( \is_null( $config ) ) {
+ return false;
+ }
+
+ $release = self::get_release( $config );
+ if ( \is_null( $release ) || ! is_wp_version_compatible( $release['requires'] ) ) {
return false;
}
@@ -77,7 +81,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
}
$config = self::get_updater_config( $plugin_file );
- if ( null === $config || ( \get_object_vars( $args )['slug'] ?? null ) !== $config['slug'] ) {
+ if ( \is_null( $config ) || ( $args->slug ?? null ) !== $config['slug'] ) {
return $result;
}
@@ -92,7 +96,7 @@ static function ( $result, $action, $args ) use ( $plugin_file ) {
);
$release = self::get_release( $config );
- if ( null !== $release ) {
+ if ( ! \is_null( $release ) ) {
$information = array(
'version' => $release['version'],
'download_link' => $release['package'],
@@ -179,9 +183,9 @@ protected static function get_updater_config( string $plugin_file ): ?array {
}
/**
- * Returns the newest usable release on the configured channel, from the plugin's cache while it holds one, and otherwise from GitHub with the requirements of its tagged entry file.
+ * Returns the channel's newest release when its asset and the requirements of its tagged entry file are usable, from the plugin's cache while it holds one and otherwise from GitHub, or null.
*
- * A usable release is cached for an hour, and a failed fetch or an unusable release as empty for five minutes, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included.
+ * A failed fetch or an unusable release is cached as empty, so update checks back off from a failing API. GitHub's release list is not ordered by version, so the prerelease channel takes the highest version among the ten it lists first, stable releases included.
*
* @since 2.0.0
* @version 2.0.0
@@ -197,28 +201,31 @@ protected static function get_release( array $config ): ?array {
return null;
}
- $release = self::read_release( $cached );
- if ( null !== $release ) {
- return $release;
+ $cached_release = self::read_release( $cached );
+ if ( ! \is_null( $cached_release ) ) {
+ return $cached_release;
}
$body = self::get_response_body( "https://api.github.com/repos/{$config['repository']}/" . ( 'prerelease' === $config['channel'] ? 'releases?per_page=10' : 'releases/latest' ), array( 'Accept' => 'application/vnd.github.html+json' ) );
- $releases = null === $body ? null : \json_decode( $body, true );
+ $releases = \is_null( $body ) ? null : \json_decode( $body, true );
- $newest = array();
- $tag = '';
- $version = '';
- foreach ( ( \is_array( $releases ) && 'prerelease' === $config['channel'] ) ? $releases : array( $releases ) as $candidate ) {
+ $candidates = \is_array( $releases ) && 'prerelease' === $config['channel'] ? $releases : array( $releases );
+ $newest = array();
+ $tag = '';
+ $version = '';
+ foreach ( $candidates as $candidate ) {
if ( ! \is_array( $candidate ) || true === ( $candidate['draft'] ?? false ) || ! \is_string( $candidate['tag_name'] ?? null ) ) {
continue;
}
$candidate_version = \ltrim( $candidate['tag_name'], 'v' );
- if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) { // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'.
+ // PHP's version_compare() orders only these labels below their release as their names say, and misorders others, such as 'pre', 'patch' or 'Beta'.
+ if ( 1 !== \preg_match( '/^\d+(?:\.\d+)*(?:-(?:dev|alpha|beta|rc)(?:\.?\d+)?)?$/', $candidate_version ) ) {
continue;
}
- if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) { // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release.
+ // GitHub's prerelease flag is set by hand, so a suffixed tag can be the latest release.
+ if ( 'stable' === $config['channel'] && false !== \strpos( $candidate_version, '-' ) ) {
continue;
}
@@ -237,8 +244,9 @@ protected static function get_release( array $config ): ?array {
}
}
- $entry_file = null === $package ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors.
- if ( null !== $entry_file ) {
+ $release = null;
+ $entry_file = \is_null( $package ) ? null : self::get_response_body( "https://raw.githubusercontent.com/{$config['repository']}/" . \rawurlencode( $tag ) . "/{$config['file']}" ); // phpcs:ignore PluginCheck.CodeAnalysis.Offloading.OffloadedContent -- Reads the release's plugin headers, never served to visitors.
+ if ( ! \is_null( $entry_file ) ) {
// WordPress's get_file_data() reads only paths, so the fetched entry file is parsed with the same pattern.
$entry_file = \str_replace( "\r", "\n", \substr( $entry_file, 0, 8 * KB_IN_BYTES ) );
$headers = array(
@@ -266,7 +274,7 @@ protected static function get_release( array $config ): ?array {
}
}
- set_transient( $transient, $release ?? array(), null === $release ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS );
+ set_transient( $transient, $release ?? array(), \is_null( $release ) ? 5 * MINUTE_IN_SECONDS : HOUR_IN_SECONDS );
return $release;
}
@@ -303,7 +311,7 @@ protected static function read_release( $value ): ?array {
* @version 2.0.0
*
* @param string $url The URL.
- * @param array $headers Optional. The request's headers. Default none.
+ * @param array $headers Optional. The request's headers. Default is none.
*
* @return string|null
*/
diff --git a/packages/core/README.md b/packages/core/README.md
index efcc3920..ceb47b32 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -4,7 +4,7 @@ The plugin kernel. A plugin is composed of small components that load only where
## Requirements
-It builds on wp-framework-bootstrap and wp-framework-shared, and on the PSR container, log, clock and simple-cache interfaces. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses.
+It builds on wp-framework-bootstrap and wp-framework-shared, on the PSR container, log, clock and simple-cache interfaces, and on PHP's filter extension. Its `composer.json` holds its PHP floor under `require.php` and its WordPress floor under `extra.requires-wp`, which a plugin's `Requires PHP` and `Requires at least` headers meet. The kernel builds components through a PSR-11 container the plugin chooses.
## Installation
@@ -112,7 +112,7 @@ function acme_pc_boot(): void {
}
```
-One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments; the composition registers the scheduler and the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container and registers its hooks:
+One composition root builds the plugin. `PluginBoot` runs its composition and, when it throws, logs the failure and tells administrators instead of breaking the site, rethrowing only in local and development environments. The composition registers the migration runner, over the steps that the plugin's `includes/migrations.php` returns as prefixed functions, then boots the kernel, which builds each top-level component through the container, the scheduler first, and registers its hooks:
```php
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
@@ -121,6 +121,7 @@ use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationR
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginBoot;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\PluginKernel;
use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler;
+use Acme\PersonaCore\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface;
use Acme\PersonaCore\Scoped\DI\ContainerBuilder;
use Acme\PersonaCore\Scoped\Psr\Log\LoggerInterface as PsrLoggerInterface;
@@ -130,6 +131,7 @@ final class Plugin {
// ...
public const array COMPONENTS = array(
+ Scheduler::class,
AdminNotices::class,
Greeting::class,
);
@@ -138,24 +140,23 @@ final class Plugin {
public function __construct() {
$this->logger = new ErrorLogLogger( self::SLUG );
- $this->boot = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger );
$this->scheduler = new Scheduler( self::PREFIX );
+ $this->boundary = new PluginBoot( ACME_PC_FILE, self::PREFIX, $this->logger );
}
// ...
public function boot(): void {
- $this->boot->run( $this->compose( ... ) );
+ $this->boundary->run( $this->compose( ... ) );
}
// ...
protected function compose(): bool {
- $this->scheduler->register_hooks();
$this->migrations = new MigrationRunner( ACME_PC_FILE, self::PREFIX . '_revision', acme_pc_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
- $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger ) )->build() );
+ $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
$this->kernel->boot( self::COMPONENTS );
return true;
@@ -163,9 +164,12 @@ final class Plugin {
// ...
- protected function get_definitions( PsrLoggerInterface $logger ): array {
+ protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array {
return array(
- AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
+ PsrLoggerInterface::class => static fn (): PsrLoggerInterface => $logger,
+ Scheduler::class => static fn (): Scheduler => $scheduler,
+ SchedulerInterface::class => static fn (): Scheduler => $scheduler,
+ AdminNotices::class => static fn (): AdminNotices => new AdminNotices( self::PREFIX, $logger ),
);
}
@@ -188,6 +192,34 @@ final class Greeting implements ComponentInterface {
}
```
+The plugin's `uninstall.php` hands its footprint to `Uninstaller::run()`, which unschedules the plugin's jobs on every site and deletes the rest of the footprint on each site that consents. The footprint names the plugin's data by kind: `options`, `option_prefixes`, `transients`, `network_options`, `post_types`, `comment_types`, `meta` and `meta_prefixes` by meta type, `user_meta_site`, `user_meta_global` and their `_prefixes`, `capabilities`, `tables`, `cron_hooks`, `as_hooks` and `as_groups`. A site consents through an option that holds `yes`, a key holding `yes` in an option's array, or a constant defined as `true`. The file loads the engine as the main file does, and on a site below the floors or with an incomplete build it keeps the data:
+
+```php
+function acme_pc_get_footprint(): array {
+ return array(
+ 'option_prefixes' => array( Plugin::PREFIX . '_' ),
+ 'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
+ 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
+ 'network_options' => array( Plugin::PREFIX . '_network_notices' ),
+ 'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ),
+ 'as_groups' => array( Plugin::PREFIX ),
+ );
+}
+```
+
+```php
+Uninstaller::run(
+ $acme_pc_file,
+ $acme_pc_footprint,
+ array(
+ 'consent' => array(
+ 'option' => $acme_pc_consent,
+ 'key' => 'delete_data',
+ ),
+ )
+);
+```
+
## Contract and versioning
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
diff --git a/packages/core/src/AdminNotice/AdminNotices.php b/packages/core/src/AdminNotice/AdminNotices.php
index 387d9593..fb1f7f83 100644
--- a/packages/core/src/AdminNotice/AdminNotices.php
+++ b/packages/core/src/AdminNotice/AdminNotices.php
@@ -69,7 +69,7 @@ final class AdminNotices implements ComponentInterface {
*
* @var bool
*/
- protected bool $needs_dismiss_script = false;
+ protected bool $needs_dismissal_script = false;
// endregion
@@ -81,8 +81,8 @@ final class AdminNotices implements ComponentInterface {
* @since 2.0.0
* @version 2.0.0
*
- * @param string $prefix The plugin's prefix, matching PREFIX_PATTERN without a reserved segment.
- * @param PsrLoggerInterface $logger The logger that records storage failures.
+ * @param string $prefix The plugin's prefix: a lowercase letter followed by up to 31 lowercase letters, digits and underscores, with no network or hidden segment.
+ * @param PsrLoggerInterface $logger The logger that records failures to read, write or filter notices.
*
* @throws InvalidArgumentException Thrown when the prefix is invalid.
*/
@@ -99,6 +99,28 @@ public function __construct(
// endregion
+ // region GETTERS
+
+ /**
+ * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param NoticeScope $scope The scope.
+ * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default is 0.
+ *
+ * @throws InvalidArgumentException Thrown when a user is given for another scope.
+ * @throws LogicException Thrown when user notices have no user and nobody is logged in.
+ *
+ * @return list
+ */
+ public function get_notices( NoticeScope $scope, int $user_id = 0 ): array {
+ return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) );
+ }
+
+ // endregion
+
// region INHERITED METHODS
/**
@@ -110,9 +132,9 @@ public function __construct(
#[\Override]
public function register_hooks(): void {
add_action( 'all_admin_notices', array( $this, 'render_notices' ) );
- add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_script' ) );
+ add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_delivery_script' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
- add_action( 'admin_footer', array( $this, 'enqueue_dismiss_script' ) );
+ add_action( 'admin_footer', array( $this, 'enqueue_dismissal_script' ) );
}
// endregion
@@ -128,7 +150,7 @@ public function register_hooks(): void {
* @version 2.0.0
*
* @param Notice $notice The notice.
- * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0.
+ * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0.
*
* @throws InvalidArgumentException Thrown when a user is given for a notice of another scope.
* @throws LogicException Thrown when a user notice has no user and nobody is logged in.
@@ -158,7 +180,7 @@ public function add( Notice $notice, int $user_id = 0 ): void {
*
* @param string $id The notice's id.
* @param NoticeScope $scope The notice's scope.
- * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default 0.
+ * @param int $user_id Optional. The user of a user notice, where 0 is the current user. Default is 0.
*
* @throws InvalidArgumentException Thrown when a user is given for another scope.
* @throws LogicException Thrown when a user notice has no user and nobody is logged in.
@@ -169,123 +191,6 @@ public function remove( string $id, NoticeScope $scope, int $user_id = 0 ): void
$this->delete( $scope, $this->get_owner( $scope, $user_id ), $id );
}
- /**
- * Returns the scope's unexpired notices for the current site, network or user, in the order they were first added.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param NoticeScope $scope The scope.
- * @param int $user_id Optional. The user of user notices, where 0 is the current user. Default 0.
- *
- * @throws InvalidArgumentException Thrown when a user is given for another scope.
- * @throws LogicException Thrown when user notices have no user and nobody is logged in.
- *
- * @return list
- */
- public function get_notices( NoticeScope $scope, int $user_id = 0 ): array {
- return \array_values( $this->read( $scope, $this->get_owner( $scope, $user_id ) ) );
- }
-
- /**
- * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens.
- *
- * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A scope whose storage throws is logged and left out.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param bool $block_editor Whether to select the block editor's notices.
- *
- * @return list
- */
- protected function select( bool $block_editor ): array {
- $user_id = get_current_user_id();
- if ( 0 === $user_id ) {
- return array();
- }
-
- $selected = array();
- foreach ( self::ORDER as $scope ) {
- $shows_here = match ( true ) {
- $block_editor => NoticeScope::Request !== $scope,
- is_network_admin() => NoticeScope::Site !== $scope,
- is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope,
- default => is_blog_admin(),
- };
- if ( ! $shows_here ) {
- continue;
- }
-
- try {
- foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) {
- if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) {
- continue;
- }
-
- if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) {
- continue;
- }
-
- if ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) {
- continue;
- }
-
- $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null );
- if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) {
- continue;
- }
-
- /**
- * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param bool $show Whether the notice shows. Only true shows it.
- * @param string $id The notice's id.
- * @param string $scope The notice's scope.
- */
- if ( true === apply_filters( "{$this->prefix}/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
- $selected[] = $notice;
- }
- }
- } catch ( \Throwable $exception ) {
- $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception );
- }
- }
-
- return $selected;
- }
-
- /**
- * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it.
- *
- * A throw from storage is logged, so the notice may show again.
- *
- * @since 2.0.0
- * @version 2.0.0
- *
- * @param Notice $notice The notice as it was selected.
- *
- * @return void
- */
- protected function acknowledge( Notice $notice ): void {
- if ( $notice->persistent ) {
- return;
- }
-
- try {
- if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) {
- $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id );
- } else {
- $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' );
- }
- } catch ( \Throwable $exception ) {
- $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception );
- }
- }
-
// endregion
// region HOOKS
@@ -313,7 +218,7 @@ public function render_notices(): void {
);
if ( $notice->persistent && $notice->dismissible ) {
$attributes['data-notice-nonce'] = wp_create_nonce( $this->get_nonce_action( $notice->scope, $notice->id ) );
- $this->needs_dismiss_script = true;
+ $this->needs_dismissal_script = true;
}
wp_admin_notice(
@@ -374,23 +279,21 @@ public function register_rest_routes(): void {
}
/**
- * Enqueues, on the post editor, the script that shows the current user's block-editor notices, fetched from the delivery endpoint as the editor loads with one-shots waiting and after each save that is not an autosave.
- *
- * The load only reports whether one-shots wait, because the meta boxes' save loads the edit screen in the background and discards it. The editor.savePost handler runs at priority 20, after the meta boxes save at 10, so it fetches the rejections that save reported, and it never throws, because a throw there reports the save as failed. Messages show as HTML, as on classic screens.
+ * Enqueues, on the post editor, the script that shows the current user's block-editor notices.
*
* @since 2.0.0
* @version 2.0.0
*
* @return void
*/
- public function enqueue_editor_script(): void {
+ public function enqueue_delivery_script(): void {
// The other block editors, such as the site editor while it browses, show no notices, so a one-shot delivered there would be lost.
$screen = \function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( true !== $screen?->is_block_editor() || 'post' !== $screen->base ) {
return;
}
- $handle = "{$this->prefix}-notice-delivery";
+ $handle = "$this->prefix-notice-delivery";
$settings = wp_json_encode(
array(
'prefix' => $this->prefix,
@@ -399,7 +302,7 @@ public function enqueue_editor_script(): void {
)
);
- wp_register_script( $handle, false, array( 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
+ wp_register_script( $handle, false, array( 'wp-a11y', 'wp-api-fetch', 'wp-data', 'wp-hooks', 'wp-notices' ), null, true ); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- A handle without a source loads no file to cache.
wp_add_inline_script(
$handle,
<<<'JS'
@@ -410,17 +313,25 @@ function deliver() {
wp.data.dispatch( 'core/notices' ).createNotice( notice.status, notice.content, {
id: notice.id,
isDismissible: notice.isDismissible,
+ speak: false,
+ // Messages show as HTML, as on classic screens.
__unstableHTML: true
} );
+ // The notices store would read the markup aloud with its entities spelled out.
+ wp.a11y.speak( notice.spoken, 'error' === notice.status ? 'assertive' : 'polite' );
} );
} ).catch( function ( error ) {
+ // A throw from an editor.savePost handler reports the save as failed.
console.error( 'The notice delivery failed.', error );
} );
}
+ // The page only reports whether one-shots wait, because the meta boxes' save loads this screen in the background and discards it.
if ( settings.waiting ) {
deliver();
}
+
+ // After the meta boxes save at priority 10, so the delivery brings the rejections that save reported.
wp.hooks.addAction( 'editor.savePost', settings.prefix + '/notices', function ( post, options ) {
if ( ! ( options && options.isAutosave ) ) {
deliver();
@@ -442,12 +353,12 @@ function deliver() {
*
* @return void
*/
- public function enqueue_dismiss_script(): void {
- if ( ! $this->needs_dismiss_script ) {
+ public function enqueue_dismissal_script(): void {
+ if ( ! $this->needs_dismissal_script ) {
return;
}
- $handle = "{$this->prefix}-notice-dismissal";
+ $handle = "$this->prefix-notice-dismissal";
$settings = wp_json_encode(
array(
'prefix' => $this->prefix,
@@ -502,7 +413,7 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP
$nonce = $params['nonce'] ?? null;
$user_id = get_current_user_id();
- if ( null === $scope || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) {
+ if ( \is_null( $scope ) || ! \is_string( $id ) || 1 !== \preg_match( Notice::ID_PATTERN, $id ) || ! \is_string( $nonce ) ) {
return new \WP_Error( 'rest_invalid_param', 'The notice scope, id or nonce is invalid.', array( 'status' => 400 ) );
}
@@ -515,16 +426,16 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP
$notice = null;
if ( NoticeScope::Request !== $scope ) {
$notice = $this->read( $scope, $owner )[ $id ] ?? null;
- if ( null === $notice ) {
+ if ( \is_null( $notice ) ) {
return new \WP_Error( 'rest_notice_invalid_id', 'No stored notice has that id.', array( 'status' => 404 ) );
}
- if ( ! $notice->persistent || ! $notice->dismissible || ( null !== $notice->capability && ! current_user_can( $notice->capability ) ) ) {
+ if ( ! $notice->persistent || ! $notice->dismissible || ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) ) {
return new \WP_Error( 'rest_forbidden', 'This user cannot dismiss that notice.', array( 'status' => 403 ) );
}
}
- $changed = null !== $notice && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' );
+ $changed = ! \is_null( $notice ) && $notice->is_shared() ? $this->delete( $scope, $owner, $id ) : $this->hide( $scope, $id, $user_id, 'dismissed' );
} catch ( \Throwable $exception ) {
$this->log( 'Could not dismiss a {scope} notice: {message}', $scope, $exception );
return new \WP_Error( 'rest_notice_store_error', 'The notice store failed.', array( 'status' => 500 ) );
@@ -542,27 +453,32 @@ public function rest_dismiss( \WP_REST_Request $request ): \WP_REST_Response|\WP
* @param int $user_id The user who dismissed it.
* @param int $owner The notice's owner: 0 for a request notice, otherwise the user, blog or network id.
*/
- do_action( "{$this->prefix}/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
+ do_action( "$this->prefix/admin_notice/dismissed", $id, $scope->value, $user_id, $owner );
}
return new \WP_REST_Response( array( 'changed' => $changed ) );
}
/**
- * Returns the one-shots the block editor shows the current user, as its notices store takes them, and acknowledges them.
+ * Returns the one-shots the block editor shows the current user, as its notices store takes them with a plain-text copy to speak, and acknowledges them.
*
* @since 2.0.0
* @version 2.0.0
*
- * @return list
+ * @return list
*/
public function rest_deliver(): array {
$delivered = array();
foreach ( $this->select( true ) as $notice ) {
+ $content = wp_kses_post( $this->get_message( $notice ) );
+
+ // A space before each block element keeps a screen reader from running paragraphs and list items together.
+ $spaced = (string) \preg_replace( '#<(?=/?(?:p|br|div|li|ul|ol|h[1-6]|blockquote|pre|t[rdh]|d[ldt]|hr)\b)#i', ' <', $content );
$delivered[] = array(
'id' => $this->get_dom_id( $notice ),
'status' => $notice->type->value,
- 'content' => wp_kses_post( $this->get_message( $notice ) ),
+ 'content' => $content,
+ 'spoken' => \html_entity_decode( wp_strip_all_tags( $spaced, true ), \ENT_QUOTES | \ENT_HTML5, 'UTF-8' ),
'isDismissible' => $notice->dismissible,
);
$this->acknowledge( $notice );
@@ -575,6 +491,105 @@ public function rest_deliver(): array {
// region HELPERS
+ /**
+ * Returns the notices the current user sees here in scope order, or for the block editor the site, network and user one-shots that name no screens.
+ *
+ * Persistent notices wait for a classic screen, whose dismissal they need, and the block editor's delivery endpoint has no screen a notice could name. A throw while a scope's notices are read or filtered is logged and ends that scope's selection.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param bool $block_editor Whether to select the block editor's notices.
+ *
+ * @return list
+ */
+ protected function select( bool $block_editor ): array {
+ $user_id = get_current_user_id();
+ if ( 0 === $user_id ) {
+ return array();
+ }
+
+ $selected = array();
+ foreach ( self::ORDER as $scope ) {
+ $shows_here = match ( true ) {
+ $block_editor => NoticeScope::Request !== $scope,
+ is_network_admin() => NoticeScope::Site !== $scope,
+ is_user_admin() => NoticeScope::Request === $scope || NoticeScope::User === $scope,
+ default => is_blog_admin(),
+ };
+ if ( ! $shows_here ) {
+ continue;
+ }
+
+ try {
+ foreach ( $this->read( $scope, $this->get_owner( $scope, 0 ) ) as $notice ) {
+ if ( $block_editor && ( $notice->persistent || array() !== $notice->screens ) ) {
+ continue;
+ }
+
+ if ( array() !== $notice->screens && ! \in_array( get_current_screen()?->id, $notice->screens, true ) ) {
+ continue;
+ }
+
+ if ( ! \is_null( $notice->capability ) && ! current_user_can( $notice->capability ) ) {
+ continue;
+ }
+
+ $state = $notice->is_shared() ? null : ( $this->get_hidden( $scope, $user_id )[ self::get_hidden_key( $scope, $notice->id ) ]['state'] ?? null );
+ if ( 'dismissed' === $state || ( 'delivered' === $state && ! $notice->persistent ) ) {
+ continue;
+ }
+
+ /**
+ * Filters whether a notice shows to the current user, once its screens, its capability and the user's dismissal allow it.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param bool $show Whether the notice shows. Only true shows it.
+ * @param string $id The notice's id.
+ * @param string $scope The notice's scope.
+ */
+ if ( true === apply_filters( "$this->prefix/admin_notice/should_show", true, $notice->id, $scope->value ) ) {
+ $selected[] = $notice;
+ }
+ }
+ } catch ( \Throwable $exception ) {
+ $this->log( 'Could not select the {scope} notices: {message}', $scope, $exception );
+ }
+ }
+
+ return $selected;
+ }
+
+ /**
+ * Records that the current user was shown a one-shot notice, deleting it unless other viewers still need to see it.
+ *
+ * A throw from storage is logged, so the notice may show again.
+ *
+ * @since 2.0.0
+ * @version 2.0.0
+ *
+ * @param Notice $notice The notice as it was selected.
+ *
+ * @return void
+ */
+ protected function acknowledge( Notice $notice ): void {
+ if ( $notice->persistent ) {
+ return;
+ }
+
+ try {
+ if ( NoticeScope::Request === $notice->scope || NoticeScope::User === $notice->scope || $notice->is_shared() ) {
+ $this->delete( $notice->scope, $this->get_owner( $notice->scope, 0 ), $notice->id );
+ } else {
+ $this->hide( $notice->scope, $notice->id, get_current_user_id(), 'delivered' );
+ }
+ } catch ( \Throwable $exception ) {
+ $this->log( 'Could not acknowledge a {scope} notice: {message}', $notice->scope, $exception );
+ }
+ }
+
/**
* Returns the owner of the scope's notices: 0 for the request, otherwise the user, or the current blog or network, so code inside switch_to_blog() addresses the switched site.
*
@@ -592,7 +607,7 @@ public function rest_deliver(): array {
protected function get_owner( NoticeScope $scope, int $user_id ): int {
if ( NoticeScope::User !== $scope ) {
if ( 0 !== $user_id ) {
- throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user $user_id was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ throw new InvalidArgumentException( "A $scope->value notice belongs to no user, but user '$user_id' was given." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
return match ( $scope ) {
@@ -604,7 +619,7 @@ protected function get_owner( NoticeScope $scope, int $user_id ): int {
$user_id = $user_id ?: get_current_user_id();
if ( $user_id < 1 ) {
- throw new LogicException( "A user notice needs a user, but it has user $user_id." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ throw new LogicException( "A user notice needs a user, but it has user '$user_id'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
return $user_id;
@@ -634,7 +649,7 @@ protected function read( NoticeScope $scope, int $owner ): array {
$notices = array();
foreach ( \is_array( $rows ) ? $rows : array() as $row ) {
$notice = \is_array( $row ) ? Notice::from_array( $row ) : null;
- if ( null !== $notice && $scope === $notice->scope && ( null === $notice->expires_at || $notice->expires_at > \time() ) ) {
+ if ( ! \is_null( $notice ) && $scope === $notice->scope && ( \is_null( $notice->expires_at ) || $notice->expires_at > \time() ) ) {
$notices[ $notice->id ] = $notice;
}
}
@@ -787,18 +802,18 @@ protected function get_message( Notice $notice ): string {
* @param string $id The notice's id.
* @param string $scope The notice's scope.
*/
- $message = apply_filters( "{$this->prefix}/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
+ $message = apply_filters( "$this->prefix/admin_notice/message", $notice->message, $notice->id, $notice->scope->value );
return \is_string( $message ) ? $message : $notice->message;
}
/**
- * Logs a storage failure as an error.
+ * Logs a failure to read, write or filter a scope's notices as an error.
*
* @since 2.0.0
* @version 2.0.0
*
* @param string $message The message, with {scope} and {message} placeholders.
- * @param NoticeScope $scope The scope whose storage failed.
+ * @param NoticeScope $scope The scope whose notices failed.
* @param \Throwable $exception The failure.
*
* @return void
@@ -825,7 +840,7 @@ protected function log( string $message, NoticeScope $scope, \Throwable $excepti
* @return string
*/
protected function get_dom_id( Notice $notice ): string {
- return "{$this->prefix}-{$notice->scope->value}-notice-{$notice->id}";
+ return "$this->prefix-{$notice->scope->value}-notice-$notice->id";
}
/**
diff --git a/packages/core/src/AdminNotice/Notice.php b/packages/core/src/AdminNotice/Notice.php
index 905456d9..37e23bda 100644
--- a/packages/core/src/AdminNotice/Notice.php
+++ b/packages/core/src/AdminNotice/Notice.php
@@ -10,7 +10,7 @@
/**
* An admin notice: what it says, to whom, on which screens and for how long.
*
- * Stores keep the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace.
+ * AdminNotices keeps the row that to_array() returns rather than the object, because each plugin scopes this class under its own namespace.
*
* @since 2.0.0
* @version 2.0.0
@@ -40,16 +40,16 @@
* @since 2.0.0
* @version 2.0.0
*
- * @param string $id The id, unique among its scope's notices and matching ID_PATTERN; a viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id.
+ * @param string $id The id, unique among its scope's notices and matching ID_PATTERN. A viewer's recorded dismissal or delivery outlives the notice, so a new occasion needs a new id.
* @param string $message The message, as HTML in the locale of the request that creates the notice.
* @param NoticeType $type Optional. The kind of notice. Default is an error.
* @param NoticeScope $scope Optional. Where the notice belongs. Default is a user.
- * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default false.
- * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default true.
+ * @param bool $persistent Optional. Whether the notice shows until it is dismissed, removed or expired, rather than once. Default is false.
+ * @param bool $dismissible Optional. Whether a viewer can dismiss the notice. Default is true.
* @param string|null $capability Optional. The capability a viewer needs, which site and network notices require. Default is none.
* @param list $screens Optional. The ids of the screens the notice shows on. Default is every screen of its scope's surfaces.
* @param int|null $expires_at Optional. The Unix time at which the notice expires. Default is never.
- * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default false.
+ * @param bool $per_viewer Optional. Whether each viewer dismisses a site or network notice, and sees it as a one-shot, for themselves rather than for everyone, as user and request notices always do. Default is false.
*
* @throws InvalidArgumentException Thrown when the id, capability, screens or expiry is invalid.
*/
diff --git a/packages/core/src/Caching/ObjectCache.php b/packages/core/src/Caching/ObjectCache.php
index fe01f048..e2dbaae2 100644
--- a/packages/core/src/Caching/ObjectCache.php
+++ b/packages/core/src/Caching/ObjectCache.php
@@ -27,7 +27,7 @@ final class ObjectCache implements PsrCacheInterface {
*
* @param string $group The object cache group, under the plugin's own prefix.
*
- * @throws InvalidArgumentException Thrown when the group is not a lowercase letter followed by up to 63 lowercase letters, digits, underscores and hyphens.
+ * @throws InvalidArgumentException Thrown when the group is invalid.
*/
public function __construct(
protected string $group
@@ -61,13 +61,13 @@ public function get( string $key, mixed $default = null ): mixed { // phpcs:igno
*/
#[\Override]
public function set( string $key, mixed $value, null|int|\DateInterval $ttl = null ): bool {
+ $name = \md5( CacheEntries::check_key( $key ) );
$lifetime = CacheEntries::get_lifetime( $ttl );
if ( ! \is_null( $lifetime ) && $lifetime <= 0 ) {
return $this->delete( $key );
}
- $name = \md5( CacheEntries::check_key( $key ) );
- return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ), $this->group, $lifetime ?? 0 );
+ return wp_cache_set( $name, CacheEntries::wrap( $value, $this->get_salt() ?? $this->start_salt() ), $this->group, $lifetime ?? 0 );
}
/**
@@ -114,13 +114,17 @@ public function getMultiple( iterable $keys, mixed $default = null ): iterable {
return array();
}
- $salt = $this->find_salt();
+ $salt = $this->get_salt();
+ if ( \is_null( $salt ) ) {
+ return \array_fill_keys( $keys, $default );
+ }
+
$names = \array_map( 'md5', $keys );
- $entries = \is_null( $salt ) ? array() : wp_cache_get_multiple( $names, $this->group );
+ $entries = wp_cache_get_multiple( $names, $this->group );
$values = array();
foreach ( $keys as $index => $key ) {
- $entry = \is_null( $salt ) ? null : CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt );
+ $entry = CacheEntries::unwrap( $entries[ $names[ $index ] ] ?? false, $salt );
$values[ $key ] = \is_null( $entry ) ? $default : $entry[0];
}
@@ -156,13 +160,13 @@ public function setMultiple( iterable $values, null|int|\DateInterval $ttl = nul
return true;
}
- $salt = $this->get_salt();
+ $salt = $this->get_salt() ?? $this->start_salt();
foreach ( $entries as $name => $value ) {
$entries[ $name ] = CacheEntries::wrap( $value, $salt );
}
$stored = wp_cache_set_multiple( $entries, $this->group, $lifetime ?? 0 );
- return \array_all( \array_keys( $entries ), fn( $name ) => true === ( $stored[ $name ] ?? false ) );
+ return \array_all( \array_keys( $entries ), static fn ( string $name ): bool => true === ( $stored[ $name ] ?? false ) );
}
/**
@@ -211,37 +215,37 @@ public function has( string $key ): bool {
*/
protected function read( string $key ): ?array {
$name = \md5( CacheEntries::check_key( $key ) );
- $salt = $this->find_salt();
+ $salt = $this->get_salt();
return \is_null( $salt ) ? null : CacheEntries::unwrap( wp_cache_get( $name, $this->group ), $salt );
}
/**
* Returns the group's salt, or null when it has none.
*
- * Read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one.
+ * The salt is read directly, because wp_cache_get_last_changed() stores a salt and fires an action for a group without one.
*
* @since 2.0.0
* @version 2.0.0
*
* @return string|null
*/
- protected function find_salt(): ?string {
+ protected function get_salt(): ?string {
$salt = wp_cache_get( 'last_changed', $this->group );
return \is_string( $salt ) && '' !== $salt ? $salt : null;
}
/**
- * Returns the group's salt, after starting one when it has none.
+ * Starts a salt for the group and returns it.
*
- * Started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts.
+ * The salt is started through wp_cache_set_last_changed(), because wp_cache_get_last_changed() would return a stored salt that no read accepts.
*
* @since 2.0.0
* @version 2.0.0
*
* @return string
*/
- protected function get_salt(): string {
- return $this->find_salt() ?? wp_cache_set_last_changed( $this->group );
+ protected function start_salt(): string {
+ return wp_cache_set_last_changed( $this->group );
}
/**
@@ -260,12 +264,7 @@ protected function delete_names( array $names ): bool {
}
$deleted = wp_cache_delete_multiple( $names, $this->group );
- $gone = true;
- foreach ( $names as $name ) {
- $gone = ( true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) ) && $gone;
- }
-
- return $gone;
+ return \array_all( $names, fn ( string $name ): bool => true === ( $deleted[ $name ] ?? false ) || $this->is_absent( $name ) );
}
/**
diff --git a/packages/core/src/Caching/TransientCache.php b/packages/core/src/Caching/TransientCache.php
index f13c176a..4e1ae3a1 100644
--- a/packages/core/src/Caching/TransientCache.php
+++ b/packages/core/src/Caching/TransientCache.php
@@ -12,7 +12,7 @@
/**
* Caches values as transients of the current site under a prefix, and clears them all at once by starting a new generation.
*
- * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters, and holds the generation it was stored under, so that clear() leaves one row per key rather than one per generation.
+ * Each entry is named by its key's hash, because the options table matches names regardless of letter case and accents and drops the timeout of a transient name past 172 characters. Each also holds the generation it was stored under, so that clear() leaves one transient per key rather than one per generation.
*
* @since 2.0.0
* @version 2.0.0
@@ -63,7 +63,7 @@ final class TransientCache implements PsrCacheInterface {
* @param string $prefix The prefix of every transient name, under the plugin's own prefix.
* @param string|null $generation_option Optional. The option that keeps the generation, which caches that clear together share. Default is the prefix followed by cache_generation.
*
- * @throws InvalidArgumentException Thrown when the prefix is empty, surrounded by whitespace or longer than 140 characters, or the option name is empty, surrounded by whitespace or longer than 191 characters.
+ * @throws InvalidArgumentException Thrown when the prefix or the generation option name is invalid.
*/
public function __construct(
protected string $prefix,
diff --git a/packages/core/src/ConditionalComponentInterface.php b/packages/core/src/ConditionalComponentInterface.php
index 88074279..a3aeff84 100644
--- a/packages/core/src/ConditionalComponentInterface.php
+++ b/packages/core/src/ConditionalComponentInterface.php
@@ -10,7 +10,7 @@
* @since 2.0.0
* @version 2.0.0
*/
-interface ConditionalComponentInterface {
+interface ConditionalComponentInterface extends ComponentInterface {
/**
* Returns whether the component and its children load.
*
diff --git a/packages/core/src/Logging/AbstractLogger.php b/packages/core/src/Logging/AbstractLogger.php
index 415e2c27..27a78f0c 100644
--- a/packages/core/src/Logging/AbstractLogger.php
+++ b/packages/core/src/Logging/AbstractLogger.php
@@ -55,8 +55,12 @@ abstract class AbstractLogger extends PsrAbstractLogger {
* @return int
*/
protected static function get_severity( mixed $level ): int {
- $severity = \is_string( $level ) ? ( self::SEVERITIES[ $level ] ?? null ) : null;
- return $severity ?? throw new PsrInvalidArgumentException( "Log level '" . ( \is_string( $level ) ? $level : \get_debug_type( $level ) ) . "' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ if ( ! \is_string( $level ) || ! \array_key_exists( $level, self::SEVERITIES ) ) {
+ $shown = \is_string( $level ) ? $level : \get_debug_type( $level );
+ throw new PsrInvalidArgumentException( "Log level '$shown' is not a PSR-3 level." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ }
+
+ return self::SEVERITIES[ $level ];
}
/**
diff --git a/packages/core/src/Migration/MigrationRunner.php b/packages/core/src/Migration/MigrationRunner.php
index b00daf60..e3d25631 100644
--- a/packages/core/src/Migration/MigrationRunner.php
+++ b/packages/core/src/Migration/MigrationRunner.php
@@ -166,7 +166,7 @@ public function register_hooks(): void {
// endregion
- // region METHODS
+ // region HOOKS
/**
* Runs the pending steps under the lock, pausing at a batched step outside the runner's hook or once the request's budget is spent.
@@ -185,7 +185,7 @@ public function run(): void {
return;
}
- // Outside the hook a pending batched step only schedules the hook, so no lock is taken; a stale revision only leaves plain steps to the hook, which runs them too.
+ // Outside the hook a pending batched step only schedules the hook and takes no lock, and a stale cached revision at worst leaves plain steps to the hook, which runs them too.
if ( ! doing_action( $this->get_hook() ) && ( $this->steps[ $cached ] ?? null ) instanceof BatchedMigrationStepInterface ) {
$this->schedule_continuation();
return;
@@ -257,7 +257,7 @@ public function run(): void {
}
while ( $revision < $target ) {
- if ( ! $this->holds_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check.
+ if ( ! $this->refresh_lock( $revision ) ) { // The previous checkpoint fired option hooks, so another run may have taken the lock over since the last check.
return;
}
@@ -265,7 +265,7 @@ public function run(): void {
$memory_limit = wp_convert_hr_to_bytes( (string) \ini_get( 'memory_limit' ) );
$spent = ! \is_null( $this->deadline ) && ( $this->get_time() >= $this->deadline || ( 0 < $memory_limit && \memory_get_usage( true ) > 0.9 * $memory_limit ) );
if ( $spent || ( $step instanceof BatchedMigrationStepInterface && ! doing_action( $this->get_hook() ) ) ) {
- $this->unlock(); // The lock goes first, so that the continuation can take it.
+ $this->release_lock(); // The lock goes first, so that the continuation can take it.
$this->schedule_continuation();
return;
}
@@ -282,7 +282,7 @@ public function run(): void {
$step();
}
- if ( ! $this->holds_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now.
+ if ( ! $this->refresh_lock( $revision ) ) { // A run whose lock was taken over must leave the revision and the error row to the run that holds it now.
return;
}
@@ -296,7 +296,7 @@ public function run(): void {
}
}
} catch ( \Throwable $exception ) {
- if ( $this->holds_lock( $revision ?? 0 ) ) {
+ if ( $this->refresh_lock( $revision ?? 0 ) ) {
$this->record_error( $exception->getMessage(), $revision );
}
@@ -310,20 +310,16 @@ public function run(): void {
// In production the lock stays until its hold expires, when a failing step runs again; elsewhere the fixed step runs on the next request.
if ( PluginBoot::is_development_environment() ) {
- $this->unlock();
+ $this->release_lock();
throw $exception;
}
return;
}
- $this->unlock();
+ $this->release_lock();
}
- // endregion
-
- // region HOOKS
-
/**
* Tells users who can activate plugins, while steps are pending, that the last run failed or that a batched step is running in the background, with a link that runs a pending batched step now.
*
@@ -417,7 +413,7 @@ public function run_now(): void {
*
* @return bool
*/
- protected function holds_lock( int $revision ): bool {
+ protected function refresh_lock( int $revision ): bool {
try {
if ( $this->lock->refresh( $this->owner ) ) {
return true;
@@ -446,7 +442,7 @@ protected function holds_lock( int $revision ): bool {
*
* @return void
*/
- protected function unlock(): void {
+ protected function release_lock(): void {
try {
$this->lock->release( $this->owner );
} catch ( RuntimeException $exception ) {
@@ -498,7 +494,7 @@ protected function schedule_continuation(): void {
*/
protected function checkpoint( int $revision ): void {
if ( ! update_option( $this->option, $revision, true ) ) {
- throw new RuntimeException( "Could not record schema revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ throw new RuntimeException( "Could not record data revision '$revision'." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
if ( $revision > self::get_revision( "{$this->option}_high_water" ) ) {
@@ -584,7 +580,7 @@ protected function get_time(): float {
* @return non-empty-string
*/
protected function get_hook(): string {
- return "{$this->option}/migration/run";
+ return "$this->option/migration/run";
}
// endregion
diff --git a/packages/core/src/Permission/CapabilityGrants.php b/packages/core/src/Permission/CapabilityGrants.php
index e619bbc8..3e5165ac 100644
--- a/packages/core/src/Permission/CapabilityGrants.php
+++ b/packages/core/src/Permission/CapabilityGrants.php
@@ -68,7 +68,7 @@ public function register_hooks(): void {
// endregion
- // region METHODS
+ // region HOOKS
/**
* Revokes each ledgered pair no longer declared and grants each declared pair that its role lacks and the ledger does not list, once the declaration or the declared roles have changed since the last complete run.
@@ -82,7 +82,8 @@ public function register_hooks(): void {
*/
public function run(): void {
$roles = wp_roles();
- $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, \array_values( \array_filter( \array_keys( $this->grants ), $roles->is_role( ... ) ) ) ) ) );
+ $present = \array_keys( \array_intersect_key( $this->grants, $roles->roles ) );
+ $fingerprint = \md5( (string) wp_json_encode( array( $this->grants, $present ) ) );
$row = $this->get_row();
if ( $row['fingerprint'] === $fingerprint ) {
return;
@@ -178,10 +179,6 @@ public function run(): void {
$this->logger->warning( 'The roles row did not keep the capability changes {changes}, so the next request makes them again.', array( 'changes' => \implode( ', ', $failed ) ) );
}
- // endregion
-
- // region HOOKS
-
/**
* Forgets the grants of each declared role that a write of the site's roles row deletes, so that the role is granted them again once it exists anew.
*
diff --git a/packages/core/src/PluginBoot.php b/packages/core/src/PluginBoot.php
index 05260f1a..cd66d8a9 100644
--- a/packages/core/src/PluginBoot.php
+++ b/packages/core/src/PluginBoot.php
@@ -108,7 +108,7 @@ static function () use ( $plugin_file ): void {
*
* @param \Throwable $exception The failure that stopped the composition.
*/
- do_action( "{$this->prefix}/boot/failed", $exception );
+ do_action( "$this->prefix/boot/failed", $exception );
if ( self::is_development_environment() ) {
throw $exception;
}
@@ -125,7 +125,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "{$this->prefix}/boot/blocked" );
+ do_action( "$this->prefix/boot/blocked" );
return;
}
@@ -137,7 +137,7 @@ static function () use ( $plugin_file ): void {
* @since 2.0.0
* @version 2.0.0
*/
- do_action( "{$this->prefix}/boot/succeeded" );
+ do_action( "$this->prefix/boot/succeeded" );
}
/**
diff --git a/packages/core/src/PluginKernel.php b/packages/core/src/PluginKernel.php
index ced7a9d6..22f79226 100644
--- a/packages/core/src/PluginKernel.php
+++ b/packages/core/src/PluginKernel.php
@@ -118,7 +118,7 @@ protected function resolve( array $classes ): array {
$components[] = $component;
if ( \is_a( $class, CompositeComponentInterface::class, true ) ) {
- \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // The declared class names the children, so no runtime state can add an unvisited node.
+ \array_push( $components, ...$this->resolve( $class::get_child_component_classes() ) ); // Children come from a static method, so the visited set covers every node the walk can reach.
}
}
diff --git a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
index d1d6f8b5..39596378 100644
--- a/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
+++ b/packages/core/src/Scheduling/Backends/ActionSchedulerBackend.php
@@ -142,7 +142,7 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error {
*/
#[\Override]
public function unschedule_hook( string $hook ): true|\WP_Error {
- // Best effort, since Action Scheduler reports no failure: at worst the hook's pending actions stay and run, or fail without a callback, until a later call succeeds.
+ // At worst a failing database write keeps Action Scheduler's bulk cancel looping until the request's time limit, and the hook's pending actions stay until a later call succeeds.
as_unschedule_all_actions( $hook );
return true;
}
diff --git a/packages/core/src/Scheduling/Scheduler.php b/packages/core/src/Scheduling/Scheduler.php
index 28187951..eab2dec1 100644
--- a/packages/core/src/Scheduling/Scheduler.php
+++ b/packages/core/src/Scheduling/Scheduler.php
@@ -219,10 +219,12 @@ public function clear_scheduled_hook( ScheduledHook $hook ): true|\WP_Error {
*
* @since 2.0.0
* @version 2.0.0
+ *
+ * @throws InvalidArgumentException Thrown when the hook name is invalid.
*/
#[\Override]
public function unschedule_hook( string $hook ): true|\WP_Error {
- $hook = ( new ScheduledHook( $hook ) )->hook; // Given no hook name, Action Scheduler would cancel the pending actions of every hook.
+ new ScheduledHook( $hook ); // Given no hook name, Action Scheduler would cancel the pending actions of every hook.
$ready = $this->check_ready( __METHOD__ );
if ( true !== $ready ) {
return $ready;
@@ -259,7 +261,7 @@ public function unschedule_hook( string $hook ): true|\WP_Error {
* @return true|\WP_Error
*/
protected function check_ready( string $method ): true|\WP_Error {
- $error = match ( true ) {
+ $ready = match ( true ) {
0 === did_action( 'init' ) || \array_any( $this->backends, static fn ( BackendInterface $backend ): bool => BackendState::Initializing === $backend->get_state() )
=> new \WP_Error( 'scheduler_not_ready', 'Scheduling waits until init has fired and Action Scheduler has initialized.' ),
// Readiness is measured for the site the scheduler was built on, while another site may run other plugins or lack Action Scheduler's tables.
@@ -268,11 +270,11 @@ protected function check_ready( string $method ): true|\WP_Error {
default => true,
};
- if ( true !== $error ) {
- _doing_it_wrong( esc_html( $method ), esc_html( $error->get_error_message() ), '2.0.0' );
+ if ( true !== $ready ) {
+ _doing_it_wrong( esc_html( $method ), esc_html( $ready->get_error_message() ), '2.0.0' );
}
- return $error;
+ return $ready;
}
/**
@@ -306,8 +308,6 @@ protected static function get_failure( \Throwable $exception ): \WP_Error {
/**
* Cancels every pending invocation of the hook on every readable backend, then checks that none is left.
*
- * Action Scheduler reports no failed cancellation, so only the check catches one.
- *
* @since 2.0.0
* @version 2.0.0
*
diff --git a/packages/core/src/Scheduling/SchedulerInterface.php b/packages/core/src/Scheduling/SchedulerInterface.php
index eaca9e1d..16996962 100644
--- a/packages/core/src/Scheduling/SchedulerInterface.php
+++ b/packages/core/src/Scheduling/SchedulerInterface.php
@@ -49,9 +49,9 @@ public function enqueue( ScheduledHook $hook, int $priority = 10 ): true|\WP_Err
public function schedule_single( ScheduledHook $hook, int $timestamp, int $priority = 10 ): true|\WP_Error;
/**
- * Ensures that the hook recurs at the given interval, creating its chain when none exists and replacing duplicate chains, or a chain at another interval, with one chain, which also cancels a pending one-off invocation of the identity.
+ * Ensures that the hook recurs at the given interval, as one chain.
*
- * On Action Scheduler a running occurrence defers any replacement to a later call, because it queues its successor only after it completes; WP-Cron shows no running state, so it is called outside the hook's own callback.
+ * A missing chain is created at the priority, which a chain that exists keeps, and duplicate chains or a chain at another interval are replaced with one, which also cancels a pending one-off invocation of the identity. On Action Scheduler a running occurrence defers any replacement to a later call. On WP-Cron, call it outside the hook's own callback, because WP-Cron shows no running state.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/core/src/Storage/Lock.php b/packages/core/src/Storage/Lock.php
index c5313cb3..c9009741 100644
--- a/packages/core/src/Storage/Lock.php
+++ b/packages/core/src/Storage/Lock.php
@@ -12,7 +12,7 @@
/**
* Leases a named lock to one owner at a time, which refreshes its hold while it works, and lets another owner take the lock over from a hold left past its TTL.
*
- * The lock is one row of the current site's options table, written only by compare-and-swap, because add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds.
+ * The lock is one row of the current site's options table, written only through OptionRows' compare-and-swap.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/core/src/Storage/OptionRows.php b/packages/core/src/Storage/OptionRows.php
index 99978020..2eb54877 100644
--- a/packages/core/src/Storage/OptionRows.php
+++ b/packages/core/src/Storage/OptionRows.php
@@ -10,7 +10,7 @@
/**
* Reads and writes options as rows of the current site's options table, with writes that are atomic or conditional on the exact row read.
*
- * The options API decides each write from a possibly cached read, so it cannot write conditionally. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading.
+ * The options API decides each write from a possibly cached read, so add_option() overwrites a row inserted after its cached check, while update_option() and delete_option() write whatever the row holds. Conditional writes compare bytes, not the column's case-insensitive collation, and every write that changes a row clears the option's cache entries, so get_option() reads it. Rows are written without autoload, so a replace also stops an autoloaded option from autoloading.
*
* @since 2.0.0
* @version 2.0.0
@@ -64,7 +64,7 @@ public static function get( string $name ): ?OptionRow {
self::check_name( $name );
- // A row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row.
+ // The query reads a row rather than a single value, because wpdb::get_var() returns null for an empty string as it does for a missing row.
$wpdb->flush();
$row = $wpdb->get_row( $wpdb->prepare( 'SELECT option_value FROM %i WHERE option_name = %s', $wpdb->options, $name ) ?? '', ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A cached value may predate another request's write.
if ( '' !== $wpdb->last_error ) { // wpdb::get_row() returns null for a failed query as for a missing row, so the error left after the flush tells them apart.
diff --git a/packages/core/src/Uninstall/Uninstaller.php b/packages/core/src/Uninstall/Uninstaller.php
index ffd6bd80..b2c83419 100644
--- a/packages/core/src/Uninstall/Uninstaller.php
+++ b/packages/core/src/Uninstall/Uninstaller.php
@@ -48,9 +48,9 @@ final class Uninstaller {
* @since 2.0.0
* @version 2.0.0
*
- * @phpstan-param array> $footprint
- * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint
- * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args
+ * @phpstan-param array> $footprint
+ * @phpstan-param array{meta: array>, meta_prefixes: array>} $meta_footprint
+ * @phpstan-param array{consent: array{option: string, key: string, constant: string}, meta_types: array, cache_groups: list}>, cleanup: (callable(array{scope: 'site'|'network', site_id: int, plugin_file: string}): void)|null} $args
*
* @param string $plugin_file The path to the plugin's entry file.
* @param array $footprint The names and prefixes of every kind but the meta kinds, by kind.
@@ -71,7 +71,7 @@ protected function __construct(
/**
* Removes the plugin's footprint from every site that consents, and unschedules its jobs on every site.
*
- * On each site the cleanup runs first, and a failed cleanup keeps that site's footprint. A failed delete leaves the others to run, while a throw from another plugin's hook ends that site's pass. Either keeps the site's consent row, and the failures are thrown once every site was visited, so WordPress keeps the plugin's files and the next uninstall retries. A job that cannot be unscheduled is logged instead, since it only ever fires into a missing callback.
+ * Failures are thrown once every site was visited, and a site that failed keeps its consent row, so WordPress keeps the plugin's files and the next uninstall retries the pass.
*
* @since 2.0.0
* @version 2.0.0
@@ -81,9 +81,9 @@ protected function __construct(
*
* @param string $plugin_file The path to the plugin's entry file.
* @param array $footprint The plugin's stored data by kind.
- * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default none, which keeps the footprint.
+ * @param array $args Optional. The consent (an option, its key in a grouped row, or a constant), the meta types the meta kinds may name beside post, term and comment, and the cleanup. Default is none, which keeps the footprint.
*
- * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a delete, a site's pass or the cleanup failed, once every site was visited.
+ * @throws RuntimeException Thrown when a web request runs on a large network, before any change, or when a read or delete, a site's pass or the cleanup failed, once every site was visited.
*
* @return void
*/
@@ -137,21 +137,22 @@ public static function run( string $plugin_file, array $footprint, array $args =
$args
);
if ( ! is_multisite() ) {
- $uninstaller->uninstall_site( true );
+ $uninstaller->uninstall_site();
} else {
if ( wp_is_large_network() && ! self::is_constant_true( 'WP_CLI' ) ) { // A web request could not finish sweeping a large network within the server's time limit, which WP-CLI does not have.
throw new RuntimeException( 'Uninstall does not sweep a large network in a web request; run `wp plugin uninstall` instead.' );
}
- foreach ( get_sites(
+ $site_ids = get_sites(
array(
'fields' => 'ids',
'number' => 0,
)
- ) as $site_id ) {
+ );
+ foreach ( $site_ids as $site_id ) {
switch_to_blog( $site_id );
try {
- $uninstaller->uninstall_site( false );
+ $uninstaller->uninstall_site();
} finally {
restore_current_blog();
if ( wp_cache_supports( 'flush_runtime' ) ) { // WordPress keeps every visited site's options in its runtime cache, so the sweep's memory would grow with each site.
@@ -172,7 +173,7 @@ public static function run( string $plugin_file, array $footprint, array $args =
}
if ( array() !== $uninstaller->failures ) {
- throw new RuntimeException( 'Uninstall could not remove everything: ' . \implode( '; ', $uninstaller->failures ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
+ throw new RuntimeException( 'Uninstall could not remove everything: ' . \rtrim( \implode( '; ', $uninstaller->failures ), '.' ) . '.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered.
}
}
@@ -188,11 +189,9 @@ public static function run( string $plugin_file, array $footprint, array $args =
*
* @global \wpdb $wpdb The WordPress database connection.
*
- * @param bool $network_footprint Whether the pass also deletes the network options and network-wide user meta, which a single site keeps as its own.
- *
* @return void
*/
- protected function uninstall_site( bool $network_footprint ): void {
+ protected function uninstall_site(): void {
global $wpdb;
$failed = \count( $this->failures );
@@ -219,7 +218,7 @@ protected function uninstall_site( bool $network_footprint ): void {
foreach ( $jobs as $subject => $arguments ) {
$wpdb->flush();
try {
- as_unschedule_all_actions( ...$arguments ); // Action Scheduler throws when its store fails to cancel an action.
+ as_unschedule_all_actions( ...$arguments ); // Action Scheduler's hybrid store, which it runs while it migrates its data, throws when it fails to cancel an action.
$result = null;
} catch ( \Throwable $throwable ) {
$result = $throwable;
@@ -293,9 +292,10 @@ protected function uninstall_site( bool $network_footprint ): void {
}
}
- $keys = $meta_keys[ $name ] ?? array();
+ $keys = $meta_keys[ $name ] ?? array();
+ $prefix_tables = '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables );
foreach ( $meta_prefixes[ $name ] ?? array() as $prefix ) {
- foreach ( '' === $meta_type['meta_type'] ? $tables : array( (string) _get_meta_table( $meta_type['meta_type'] ), ...$tables ) as $table ) {
+ foreach ( $prefix_tables as $table ) {
$keys = \array_merge( $keys, $this->get_names_by_prefix( $table, 'meta_key', $prefix ) );
}
}
@@ -312,7 +312,7 @@ protected function uninstall_site( bool $network_footprint ): void {
}
}
- if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, the entries expire on their own.
+ if ( wp_cache_supports( 'flush_group' ) ) { // Where the object cache cannot flush a single group, cached entries stay until they are invalidated or evicted.
foreach ( $meta_type['cache_groups'] as $group ) {
wp_cache_flush_group( $group );
}
@@ -338,7 +338,7 @@ protected function uninstall_site( bool $network_footprint ): void {
$this->record_failure( "table $table", $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $wpdb->prefix . $table ) ?? '' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- A plugin's own table, which no WordPress API drops.
}
- if ( $network_footprint ) {
+ if ( ! is_multisite() ) {
$this->delete_network_footprint();
}
@@ -428,7 +428,7 @@ protected function delete_meta_keys( string $meta_type, array $keys ): void {
protected function run_cleanup( string $scope ): bool {
global $wpdb;
- if ( null === $this->args['cleanup'] ) {
+ if ( \is_null( $this->args['cleanup'] ) ) {
return true;
}
@@ -560,7 +560,7 @@ protected function get_matching_ids( string $table, string $id_column, string $c
* @return \WP_Post|true|\WP_Error
*/
protected static function confirm_deleted( mixed $deleted ): mixed {
- return null === $deleted || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted;
+ return \is_null( $deleted ) || false === $deleted ? new \WP_Error( 'not_deleted', 'It was not deleted.' ) : $deleted;
}
/**
@@ -587,7 +587,7 @@ protected static function is_constant_true( string $name ): bool {
*
* @param mixed $value The value.
*
- * @return list
+ * @return list
*/
protected static function get_strings( mixed $value ): array {
return \array_values( \array_filter( \is_array( $value ) ? $value : array(), static fn ( mixed $item ): bool => \is_string( $item ) && '' !== $item && '0' !== $item ) );
@@ -602,7 +602,7 @@ protected static function get_strings( mixed $value ): array {
* @param mixed $value The meta kind's entries, by meta type name.
* @param array $meta_types The run's meta types, by name.
*
- * @return array>
+ * @return array>
*/
protected static function get_meta_strings( mixed $value, array $meta_types ): array {
return \array_map( self::get_strings( ... ), \array_intersect_key( \is_array( $value ) ? $value : array(), $meta_types ) );
diff --git a/packages/settings/README.md b/packages/settings/README.md
index bdd5e927..bc92082e 100644
--- a/packages/settings/README.md
+++ b/packages/settings/README.md
@@ -86,20 +86,29 @@ require_once __DIR__ . '/vendor/autoload.php';
require_once __DIR__ . '/functions.php';
```
-The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page:
+The helpers need no kernel. On `plugins_loaded` at priority 15 the plugin registers its settings page, and a form on the post edit screen whose rejections core's `AdminNotices` reports:
```php
+use Acme\PersonaSettings\Details;
use Acme\PersonaSettings\Settings;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger;
use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\AdminPage\OptionsPage;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\Hosts\PostMetaBox;
+use Acme\PersonaSettings\Scoped\DeepWebSolutions\Framework\Settings\EditScreen\ObjectForm;
defined( 'ABSPATH' ) || exit;
-add_action( 'plugins_loaded', 'acme_ps_register_settings_page', 15 );
+add_action( 'plugins_loaded', 'acme_ps_register_hooks', 15 );
// ...
-function acme_ps_register_settings_page(): void {
+function acme_ps_register_hooks(): void {
+ $notices = new AdminNotices( Settings::PREFIX, new ErrorLogLogger( 'acme-persona-settings' ) );
+ $notices->register_hooks();
+
new OptionsPage( Settings::PREFIX, new Settings() )->register_hooks();
+ new ObjectForm( Settings::PREFIX . '_details', new Details(), new PostMetaBox( array( 'post' ) ), $notices )->register_hooks();
}
```
@@ -123,10 +132,15 @@ final class Settings implements OptionsPageDefinitionInterface {
'option' => self::PREFIX . '_settings',
'fields' => array(
array(
- 'type' => 'number',
- 'id' => 'limit',
- 'title' => __( 'Limit', 'acme-persona-settings' ),
- 'validate' => array( $this, 'validate_limit' ),
+ 'type' => 'number',
+ 'id' => 'limit',
+ 'title' => __( 'Limit', 'acme-persona-settings' ),
+ 'default' => '10',
+ 'custom_attributes' => array(
+ 'min' => '1',
+ 'step' => '1',
+ ),
+ 'validate' => array( $this, 'validate_limit' ),
),
),
),
@@ -146,6 +160,31 @@ final class Settings implements OptionsPageDefinitionInterface {
}
```
+A form's definition object declares its fields the same way. `PostMetaBox` shows the form on the edit screens of the post types it names, `UserProfile` on the profile screens and `TermForm` on the term screens of the taxonomies it names. Each host stores a field under its id as the object's meta through `MetadataStore`, unless the form takes another store:
+
+```php
+final class Details implements ObjectFormDefinitionInterface {
+ // ...
+
+ #[\Override]
+ public function get_object_form( string $form_id, int $object_id ): array {
+ return array(
+ 'title' => __( 'Details', 'acme-persona-settings' ),
+ 'capability' => 'edit_posts',
+ 'fields' => array(
+ array(
+ 'type' => 'text',
+ 'id' => '_' . Settings::PREFIX . '_subtitle',
+ 'title' => __( 'Subtitle', 'acme-persona-settings' ),
+ ),
+ ),
+ );
+ }
+
+ // ...
+}
+```
+
## Contract and versioning
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
diff --git a/packages/settings/src/AdminPage/OptionsPage.php b/packages/settings/src/AdminPage/OptionsPage.php
index 12ac8035..3a55504d 100644
--- a/packages/settings/src/AdminPage/OptionsPage.php
+++ b/packages/settings/src/AdminPage/OptionsPage.php
@@ -17,8 +17,8 @@
*
* @phpstan-import-type Field from Forms
* @phpstan-import-type FieldRow from Forms
- * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list}
- * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list}
+ * @phpstan-type Tab array{slug: string, title: string, option: string, fields: list}
+ * @phpstan-type Page array{parent: string, capability: string, page_title: string, menu_title: string, tabs: list}
*/
final class OptionsPage {
// region FIELDS AND CONSTANTS
@@ -44,7 +44,7 @@ final class OptionsPage {
* @version 2.0.0
*
* @param string $id The page's menu slug, which also prefixes its tabs filter.
- * @param OptionsPageDefinitionInterface $definition The definition, which the page asks for once, on first use after init.
+ * @param OptionsPageDefinitionInterface $definition The page's definition.
*
* @throws InvalidArgumentException Thrown when the id is not a valid key.
*/
@@ -190,6 +190,10 @@ public function render_page(): void {
$option = $tab['option'];
$stored = get_option( $option );
foreach ( Forms::get_rows( $tab['fields'], \is_array( $stored ) ? $stored : array(), $option ) as $row ) {
+ if ( isset( $row['label_for'] ) ) { // Core prints the label through esc_attr(), which keeps an entity the control's id encodes.
+ $row['label_for'] = Forms::escape_attribute( $row['label_for'] );
+ }
+
add_settings_field( $row['field']['id'], esc_html( $row['field']['title'] ), array( $this, 'render_field' ), $this->id, 'default', $row );
}
@@ -240,7 +244,7 @@ protected function get_page(): array {
*
* @param list $tabs The page's tabs.
*/
- $page['tabs'] = apply_filters( "{$this->id}/options_page/tabs", $page['tabs'] );
+ $page['tabs'] = apply_filters( "$this->id/options_page/tabs", $page['tabs'] );
$this->page = $page;
}
diff --git a/packages/settings/src/EditScreen/FormTable.php b/packages/settings/src/EditScreen/FormTable.php
index 342a34b9..44e27b23 100644
--- a/packages/settings/src/EditScreen/FormTable.php
+++ b/packages/settings/src/EditScreen/FormTable.php
@@ -2,6 +2,8 @@
namespace DeepWebSolutions\Framework\Settings\EditScreen;
+use DeepWebSolutions\Framework\Settings\Forms;
+
\defined( 'ABSPATH' ) || exit;
/**
@@ -43,7 +45,7 @@ protected function __construct() {}
public static function render_rows( string $title, array $rows ): void {
echo '
' . $title . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes its title.
foreach ( $rows as $row ) {
- $heading = \is_null( $row['label_for'] ) ? $row['title'] : '';
+ $heading = \is_null( $row['label_for'] ) ? $row['title'] : '';
echo '
' . $heading . '
' . $row['control'] . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls.
}
echo '
';
diff --git a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php
index 7faa1786..a7a90f52 100644
--- a/packages/settings/src/EditScreen/Hosts/PostMetaBox.php
+++ b/packages/settings/src/EditScreen/Hosts/PostMetaBox.php
@@ -6,6 +6,7 @@
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectFormHostInterface;
use DeepWebSolutions\Framework\Settings\EditScreen\ObjectStoreInterface;
use DeepWebSolutions\Framework\Settings\EditScreen\Stores\MetadataStore;
+use DeepWebSolutions\Framework\Settings\Forms;
\defined( 'ABSPATH' ) || exit;
@@ -72,7 +73,8 @@ public function register_hooks( ObjectForm $form ): void {
if ( \in_array( 'attachment', $this->post_types, true ) ) {
add_action( 'edit_attachment', array( $this, 'save_attachment' ) ); // WordPress saves an attachment without firing save_post.
}
- add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 );
+
+ add_filter( 'wp_refresh_nonces', array( $this, 'refresh_nonces' ), 11, 2 ); // After core's own refreshes at 10, which create the replace maps this adds to.
}
/**
@@ -111,7 +113,7 @@ public function get_default_store(): ObjectStoreInterface {
#[\Override]
public function render_rows( string $title, array $rows ): void {
foreach ( $rows as $row ) {
- $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : '';
+ $heading = \is_null( $row['label_for'] ) ? '' . $row['title'] . '' : '';
echo '
' . $heading . '
' . $row['control'] . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- The form escapes titles and controls.
}
}
@@ -155,9 +157,10 @@ public function render_meta_box( \WP_Post $post ): void {
// The block editor's heartbeat script sets only core's nonces, by name, while the classic editor's sets every one the heartbeat returns.
if ( true === get_current_screen()?->is_block_editor() ) {
- foreach ( \array_keys( $this->form->get_refreshed_nonces( $post->ID ) ) as $field ) {
- $field = esc_js( $field );
- wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$field'] ) { jQuery( '#$field' ).val( nonces.replace['$field'] ); } } );" );
+ $nonce_field = \array_key_first( $this->form->get_refreshed_nonces( $post->ID ) );
+ if ( ! \is_null( $nonce_field ) ) {
+ $nonce_field = esc_js( $nonce_field );
+ wp_add_inline_script( 'heartbeat', "jQuery( document ).on( 'heartbeat-tick', function ( event, data ) { var nonces = data['wp-refresh-metabox-loader-nonces']; if ( nonces && nonces.replace && nonces.replace['$nonce_field'] ) { jQuery( '#$nonce_field' ).val( nonces.replace['$nonce_field'] ); } } );" );
}
}
}
diff --git a/packages/settings/src/EditScreen/ObjectForm.php b/packages/settings/src/EditScreen/ObjectForm.php
index f4c81617..00558c45 100644
--- a/packages/settings/src/EditScreen/ObjectForm.php
+++ b/packages/settings/src/EditScreen/ObjectForm.php
@@ -13,13 +13,13 @@
/**
* Adds a form of field arrays to the screen that edits an object, and stores the accepted fields as the object's data.
*
- * Its host decides where the form shows and when it saves, and its store keeps the values, by default the host's own. The form shows only to a user who may edit the object and holds the definition's capability, carries a nonce bound to the object, and reports each rejection of a save as a one-shot notice for the user, which replaces the notice of the field's earlier rejection. Controls post under the form's id, so they never meet the screen's own inputs, and each field stores under its id.
+ * Controls post under the form's id, so they never meet the screen's own inputs.
*
* @since 2.0.0
* @version 2.0.0
*
* @phpstan-import-type Field from Forms
- * @phpstan-type Form array{title: string, capability: string, fields: list}
+ * @phpstan-type Form array{title: string, capability: string, fields: list}
*/
final class ObjectForm {
// region FIELDS AND CONSTANTS
@@ -65,7 +65,7 @@ final class ObjectForm {
* @version 2.0.0
*
* @param string $id The form's id, which starts with the plugin's prefix and names the form's controls, nonce and fields filter.
- * @param ObjectFormDefinitionInterface $definition The definition, which the form asks for once per object, on first use after init.
+ * @param ObjectFormDefinitionInterface $definition The form's definition.
* @param ObjectFormHostInterface $host The host that shows and saves the form.
* @param AdminNotices $notices The plugin's admin notices, which report the rejections.
* @param ObjectStoreInterface|null $store Optional. The store of the form's values. Default is null, for the host's store.
@@ -188,7 +188,7 @@ public function render( int $object_id ): void {
/**
* Stores the accepted fields of the request's submission for the object, and reports each rejection as a one-shot error notice for the current user.
*
- * Nothing changes unless the request carries the form's nonce for the object, which binds it to the object as WordPress core binds its edit forms, so a save that fires for a revision or another object in the same request is refused, and the current user may edit the object and holds the definition's capability. A field the form did not post, because it was not rendered or is disabled, keeps its value, as a rejected one does. The form saves once per object and request, because a save that updates the object again fires the host's save hook again with the same submission.
+ * The form's nonce binds the save to the object, so a save that fires for a revision or another object in the same request changes nothing. The form saves once per object and request, because a save that updates the object again fires the host's save hook again.
*
* @since 2.0.0
* @version 2.0.0
@@ -262,7 +262,7 @@ protected function get_form( int $object_id ): array {
* @param list $fields The form's fields for the object.
* @param int $object_id The id of the object being edited or saved.
*/
- $form['fields'] = apply_filters( "{$this->id}/object_form/fields", $form['fields'], $object_id );
+ $form['fields'] = apply_filters( "$this->id/object_form/fields", $form['fields'], $object_id );
$this->forms[ $object_id ] = $form;
}
diff --git a/packages/settings/src/Forms.php b/packages/settings/src/Forms.php
index 08ad0c9d..49dd4af4 100644
--- a/packages/settings/src/Forms.php
+++ b/packages/settings/src/Forms.php
@@ -69,7 +69,7 @@ protected function __construct() {}
/**
* Returns the escaped markup of the field's control, without its row label.
*
- * A radio, a checkboxes group and a checkbox with a desc render in a fieldset named by the title, so their host prints the row label as plain text, while every other control carries the id the row label points at. An enabled checkbox or multi-value control posts a hidden input first, so a field absent from a submission was not rendered or is disabled. A field with a render callable renders through it, and the callable escapes, names and describes its control, a fieldset named by the title for a group, and, unless the field is disabled, prints a hidden empty input before it.
+ * A group, as is_group() tells, renders in a fieldset its title names, or in its host's fieldset when it has no title. A field with a render callable renders through it, and the callable escapes, names and describes its control, prints its id through escape_attribute() as the row label does, and wraps a group the same way. Unless the field is disabled, the callable also prints a hidden empty input before its control, so that a field absent from a submission reads as unrendered or disabled.
*
* @since 2.0.0
* @version 2.0.0
@@ -99,7 +99,7 @@ public static function render_control( array $field, mixed $value, string $name,
/**
* Returns the accepted values and the rejections of a submission, keyed by field id, for the defined, present and enabled fields only.
*
- * Callers merge the values onto the stored row with array_replace( $stored, $result['values'] ) and report the errors, so a submission writes no id outside the definition, and every stored id it leaves out, including those of rejected fields and of fields absent from the definition, keeps its value. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error and owns the field's allowed values, so no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices, and a rejection carries a code and a message that tells the user what to enter, which a host may replace by the code. Every WP_Error message is plain text, which the host escapes.
+ * Absent, rejected and undefined ids stay out of the values. A field with validate hands its submission, alone, to the callable, which returns the value to store or a WP_Error, and no type sanitizer runs before or after it. Without validate, the field's type sanitizes the submission and checks its choices. Every WP_Error message is plain text, which the host escapes.
*
* @since 2.0.0
* @version 2.0.0
@@ -231,7 +231,7 @@ public static function get_rejection_message( string $title, \WP_Error $error ):
*
* @param string $id The id.
*
- * @throws InvalidArgumentException Thrown when the id is not a lowercase letter, after an optional underscore, followed by lowercase letters, digits, underscores and hyphens.
+ * @throws InvalidArgumentException Thrown when the id is not a valid helper id.
*
* @return void
*/
@@ -279,7 +279,7 @@ public static function escape_attribute( string $value ): string {
}
/**
- * Returns a row for each field with an id: the field named by its title, its stored value or else its default, the name it posts under, its HTML id, and the id its row label points at unless it is a group.
+ * Returns the row each field with an id renders as.
*
* @since 2.0.0
* @version 2.0.0
@@ -478,24 +478,30 @@ protected static function render_choices( array $field, mixed $value, string $na
$disabled = self::is_disabled( $field );
$chosen = self::get_chosen( $value, $multiple );
$custom = self::get_custom_attributes( $field );
+ $untitled = '' === ( $field['title'] ?? '' );
$choices = $multiple && ! $disabled ? self::render_hidden_input( $name, '' ) : '';
$index = 0;
foreach ( $field['options'] ?? array() as $key => $label ) {
$attributes = array(
- 'type' => $multiple ? 'checkbox' : 'radio',
- 'id' => "$id.$index",
- 'name' => $multiple ? $name . '[]' : $name,
- 'value' => (string) $key,
- 'class' => $field['class'] ?? null,
- 'style' => $field['css'] ?? null,
- 'checked' => \in_array( (string) $key, $chosen, true ),
- 'disabled' => $disabled || self::is_disabled_choice( $field, $key ),
+ 'type' => $multiple ? 'checkbox' : 'radio',
+ 'id' => "$id.$index",
+ 'name' => $multiple ? $name . '[]' : $name,
+ 'value' => (string) $key,
+ 'class' => $field['class'] ?? null,
+ 'style' => $field['css'] ?? null,
+ 'checked' => \in_array( (string) $key, $chosen, true ),
+ 'disabled' => $disabled || self::is_disabled_choice( $field, $key ),
+ 'aria-describedby' => $untitled ? self::get_described_by( $help ) : null,
);
$choices .= ' ';
++$index;
}
+ if ( $untitled ) { // A host that names the group in a fieldset of its own passes no title.
+ return $choices . self::render_help( $help );
+ }
+
return '';
}
@@ -514,7 +520,9 @@ protected static function render_legend( array $field ): string {
}
/**
- * Returns the markup of the help paragraphs.
+ * Returns the markup of the help texts.
+ *
+ * A text sits in a paragraph, which WordPress's admin styles as a description, unless it holds an element that ends a paragraph, which would end it before that element and leave the element the control names empty, so such a text sits in a div.
*
* @since 2.0.0
* @version 2.0.0
@@ -526,12 +534,14 @@ protected static function render_legend( array $field ): string {
protected static function render_help( array $help ): string {
$html = '';
foreach ( $help as $help_id => $text ) {
- $html .= '
';
+ ) . ">$text$tag>";
}
return $html;
@@ -550,7 +560,11 @@ protected static function render_help( array $help ): string {
*/
protected static function render_attributes( array $own, array $custom = array() ): string {
$html = '';
- foreach ( \array_filter( $own + $custom, static fn ( string|bool|null $value ): bool => ! \is_null( $value ) && false !== $value ) as $name => $value ) {
+ foreach ( $own + $custom as $name => $value ) {
+ if ( \is_null( $value ) || false === $value ) {
+ continue;
+ }
+
$html .= true === $value ? " $name" : " $name=\"" . self::escape_attribute( $value ) . '"';
}
diff --git a/packages/shared/README.md b/packages/shared/README.md
index 5f778fb5..4de1040b 100644
--- a/packages/shared/README.md
+++ b/packages/shared/README.md
@@ -61,32 +61,65 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco
## Usage
-A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. A script loads the project's autoloader and imports the package under the project's prefix:
+A `Version` parses and compares versions, a result is a `Success` or a `Failure` whose `match()` takes one callback per branch, and a value object equals any other of its class with the same values. The package throws its mirrors of PHP's `InvalidArgumentException`, `LogicException` and `RuntimeException`, which extend them and implement `ExceptionInterface`, so a caller catches the framework's failures by that interface. A script loads the project's autoloader and imports the package under the project's prefix:
```php
+use Acme\PersonaShared\Catalog;
use Acme\PersonaShared\Point;
use Acme\PersonaShared\UnknownKey;
+use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Exception\ExceptionInterface;
use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Failure;
use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Result\Success;
use Acme\PersonaShared\Scoped\DeepWebSolutions\Framework\Shared\Versioning\Version;
require __DIR__ . '/vendor/autoload.php';
+try {
+ $acme_rejected = (string) Version::from_string( 'next' );
+} catch ( ExceptionInterface $acme_exception ) {
+ $acme_rejected = $acme_exception::class;
+}
+
+$acme_catalog = new Catalog( array( 'acme' => 'box' ) );
+
echo \json_encode(
array(
'wordpress' => \defined( 'ABSPATH' ),
'version' => array( (string) Version::from_string( '2.1.0-rc.1' ), Version::from_string( '2.1.0' )->compare( Version::from_string( '2.1.0-rc.1' ) ) ),
+ 'rejected' => $acme_rejected,
'results' => array(
Success::from( 'acme' )->match( static fn ( string $value ): string => "found $value", static fn (): string => 'failed' ),
Failure::from( new UnknownKey( 'acme' ) )->match( static fn (): string => 'found', static fn ( UnknownKey $error ): string => "unknown $error->key" ),
),
+ 'narrowed' => array( $acme_catalog->describe( 'acme' ), $acme_catalog->describe( 'other' ) ),
'equality' => array( new Point( 1, 2 )->equals( new Point( 1, 2 ) ), new Point( 1, 2 )->equals( new Point( 2, 1 ) ) ),
),
\JSON_THROW_ON_ERROR
);
```
-A failure carries an error object, and a value object declares its values as promoted properties:
+An operation with failures its caller branches on returns `AbstractResult` under `#[\NoDiscard]`, and `is_success()` or `is_failure()` narrows the result to its `value` or its `error`. A failure carries an error object, and a value object declares its values as promoted properties:
+
+```php
+final readonly class Catalog {
+ // ...
+
+ #[\NoDiscard]
+ public function find( string $key ): AbstractResult {
+ return isset( $this->values[ $key ] ) ? Success::from( $this->values[ $key ] ) : Failure::from( new UnknownKey( $key ) );
+ }
+
+ // ...
+
+ public function describe( string $key ): string {
+ $found = $this->find( $key );
+
+ return $found->is_success() ? "found $found->value" : "unknown {$found->error->key}";
+ }
+
+ // ...
+}
+```
```php
final readonly class UnknownKey implements ErrorInterface {
diff --git a/packages/shared/src/Result/AbstractResult.php b/packages/shared/src/Result/AbstractResult.php
index 2df40e7a..33aeb9b0 100644
--- a/packages/shared/src/Result/AbstractResult.php
+++ b/packages/shared/src/Result/AbstractResult.php
@@ -12,7 +12,7 @@
*
* @template-covariant TValue
* @template-covariant TError of ErrorInterface
- * @phpstan-sealed Success|Failure
+ * @phpstan-sealed Success|Failure
*/
abstract readonly class AbstractResult {
// region METHODS
diff --git a/packages/shared/src/Result/Failure.php b/packages/shared/src/Result/Failure.php
index f3a883a5..c9af4454 100644
--- a/packages/shared/src/Result/Failure.php
+++ b/packages/shared/src/Result/Failure.php
@@ -11,7 +11,7 @@
* @version 2.0.0
*
* @template-covariant TError of ErrorInterface
- * @extends AbstractResult
+ * @extends AbstractResult
*/
final readonly class Failure extends AbstractResult {
// region MAGIC METHODS
diff --git a/packages/shared/src/Result/Success.php b/packages/shared/src/Result/Success.php
index 1ae19194..37195696 100644
--- a/packages/shared/src/Result/Success.php
+++ b/packages/shared/src/Result/Success.php
@@ -9,7 +9,7 @@
* @version 2.0.0
*
* @template-covariant TValue
- * @extends AbstractResult
+ * @extends AbstractResult
*/
final readonly class Success extends AbstractResult {
// region MAGIC METHODS
diff --git a/packages/shared/src/Versioning/Version.php b/packages/shared/src/Versioning/Version.php
index d02c50ba..ec7270cb 100644
--- a/packages/shared/src/Versioning/Version.php
+++ b/packages/shared/src/Versioning/Version.php
@@ -6,7 +6,7 @@
use DeepWebSolutions\Framework\Shared\ValueObject\AbstractValueObject;
/**
- * A version the plugin controls, ordered by precedence.
+ * A version the project controls, ordered by precedence.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/woocommerce/README.md b/packages/woocommerce/README.md
index fc5b3dd7..34883bc5 100644
--- a/packages/woocommerce/README.md
+++ b/packages/woocommerce/README.md
@@ -4,7 +4,7 @@ The framework's settings, edit-screen and logging helpers carried onto WooCommer
## Requirements
-It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least` and `Requires Plugins` headers meet. The plugin's own `WC requires at least` header is the WooCommerce floor that `WooCommerceRequirement` checks.
+It builds on wp-framework-settings, which brings wp-framework-core, wp-framework-bootstrap and wp-framework-shared. Its `composer.json` holds its PHP floor under `require.php`, its WordPress floor under `extra.requires-wp`, its WooCommerce floor under `extra.requires-wc` and WooCommerce under `extra.requires-plugins`, which a plugin's `Requires PHP`, `Requires at least`, `WC requires at least` and `Requires Plugins` headers meet. `WooCommerceRequirement` checks the plugin's own `WC requires at least` header at runtime.
## Installation
@@ -67,6 +67,8 @@ return ( require __DIR__ . '/vendor/ahegyes/wordpress-configs/php/php-scoper/sco
## Usage
+`Logging/` extends core's logging and `EditScreen/` settings' object forms, while `ProductData/` and `Setting/` are WooCommerce screens built on settings' `Forms`. A `SettingsTab` adds a WooCommerce settings tab, and a `SettingsSection`, constructed in a `woocommerce_settings_sections_registration` callback, adds a section to an existing one. An `OrderMetaBox` hosts an object form on the order edit screens and stores through `OrderMetaStore`, and `ProductMeta::get_value()` reads a product field's stored value, or a fallback only where the meta is absent.
+
The plugin's main file requires `load.php` from bootstrap's prefixed copy, then its autoloader once the floor check passes:
```php
@@ -101,12 +103,14 @@ use Acme\PersonaWooCommerce\Scoped\DeepWebSolutions\Framework\WooCommerce\WooCom
defined( 'ABSPATH' ) || exit;
+require_once __DIR__ . '/includes/options.php';
+
add_action( 'plugins_loaded', 'acme_pw_register_hooks', 15 );
// ...
function acme_pw_register_hooks(): void {
- if ( ! new WooCommerceRequirement( ACME_PW_FILE )->check() ) {
+ if ( ! WooCommerceRequirement::check( ACME_PW_FILE ) ) {
return;
}
@@ -151,6 +155,12 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
'id' => self::PREFIX . '_default_box',
'title' => __( 'Default box', 'acme-persona-woocommerce' ),
),
+ array(
+ 'type' => 'checkbox',
+ 'id' => ACME_PW_OPTION_DELETE_DATA,
+ 'title' => __( 'Data on uninstall', 'acme-persona-woocommerce' ),
+ 'desc' => __( 'Delete the packing data when the plugin is uninstalled', 'acme-persona-woocommerce' ),
+ ),
);
}
@@ -165,7 +175,7 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
'fields' => array(
array(
'type' => 'text',
- 'id' => '_' . self::PREFIX . '_box',
+ 'id' => ACME_PW_META_BOX,
'label' => __( 'Box', 'acme-persona-woocommerce' ),
),
),
@@ -178,6 +188,19 @@ final class Packing implements ProductDataTabDefinitionInterface, SectionFieldsD
}
```
+The plugin's `uninstall.php` runs core's `Uninstaller` as core's README shows, and passes `MetaTypes::DEFINITIONS`, so that a footprint can name the `order` and `order_item` meta kinds, whose WooCommerce tables the definitions name, so the uninstall finds them while WooCommerce is inactive:
+
+```php
+Uninstaller::run(
+ $acme_pw_file,
+ $acme_pw_footprint,
+ array(
+ 'consent' => array( 'option' => $acme_pw_consent ),
+ 'meta_types' => $acme_pw_meta_types,
+ )
+);
+```
+
## Contract and versioning
The five packages release together under one version, which the [changelog](https://github.com/ahegyes/wordpress-framework/blob/trunk/CHANGELOG.md) records.
diff --git a/packages/woocommerce/composer.json b/packages/woocommerce/composer.json
index 52112da6..5b40f0a8 100644
--- a/packages/woocommerce/composer.json
+++ b/packages/woocommerce/composer.json
@@ -18,6 +18,7 @@
"dev-trunk": "2.x-dev"
},
"requires-wp": "7.1",
+ "requires-wc": "11.2",
"requires-plugins": [
"woocommerce"
],
diff --git a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
index a80db01f..bd2fb330 100644
--- a/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
+++ b/packages/woocommerce/src/EditScreen/Hosts/OrderMetaBox.php
@@ -101,7 +101,7 @@ public function get_default_store(): ObjectStoreInterface {
*/
#[\Override]
public function render_rows( string $title, array $rows ): void {
- ( new PostMetaBox( array() ) )->render_rows( $title, $rows );
+ new PostMetaBox( array() )->render_rows( $title, $rows );
}
// endregion
@@ -147,7 +147,7 @@ public function render_meta_box( mixed $order ): void {
/**
* Saves the form when WooCommerce saves an order from its edit screen, before WooCommerce saves the order's data, so that a status change and its emails see the form's values.
*
- * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists; only the box's own screens print the form's nonce.
+ * On the order tables WooCommerce fires the hook for every order type, and in the posts table only for the types wc_get_order_types( 'order-meta-boxes' ) lists. Only the box's own screens print the form's nonce.
*
* @since 2.0.0
* @version 2.0.0
diff --git a/packages/woocommerce/src/Logging/WooCommerceLogger.php b/packages/woocommerce/src/Logging/WooCommerceLogger.php
index 62488808..06378438 100644
--- a/packages/woocommerce/src/Logging/WooCommerceLogger.php
+++ b/packages/woocommerce/src/Logging/WooCommerceLogger.php
@@ -19,14 +19,14 @@ final class WooCommerceLogger extends AbstractLogger {
// region FIELDS AND CONSTANTS
/**
- * The severity of the lowest level the threshold logs, or null for the WP_DEBUG default.
+ * The severity of the lowest level logged, or null for the WP_DEBUG default.
*
* @since 2.0.0
* @version 2.0.0
*
* @var int|null
*/
- protected ?int $floor = null;
+ protected ?int $threshold = null;
// endregion
@@ -50,7 +50,7 @@ public function __construct(
protected ?\WC_Logger_Interface $logger = null
) {
if ( ! \is_null( $threshold ) ) {
- $this->floor = self::get_severity( $threshold );
+ $this->threshold = self::get_severity( $threshold );
}
}
@@ -77,7 +77,7 @@ public function __construct(
#[\Override]
public function log( mixed $level, string|\Stringable $message, array $context = array() ): void {
$severity = self::get_severity( $level );
- if ( $severity < ( $this->floor ?? self::get_default_threshold() ) ) {
+ if ( $severity < ( $this->threshold ?? self::get_default_threshold() ) ) {
return;
}
@@ -103,7 +103,12 @@ public function log( mixed $level, string|\Stringable $message, array $context =
$this->logger ??= \function_exists( 'wc_get_logger' ) ? wc_get_logger() : null;
$this->logger?->log( $level, $message, array( 'source' => $this->source ) + $encoded );
- if ( $severity >= self::SEVERITIES[ PsrLogLevel::ERROR ] && ( ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity ) ) {
+ if ( $severity < self::SEVERITIES[ PsrLogLevel::ERROR ] ) {
+ return;
+ }
+
+ $dropped = ! \class_exists( LoggingUtil::class ) || ! LoggingUtil::logging_is_enabled() || \WC_Log_Levels::get_level_severity( LoggingUtil::get_level_threshold() ) > $severity;
+ if ( $dropped ) {
\error_log( self::format_record( $this->source, $level, $message, $context ) ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Errors must stay visible when WooCommerce drops them.
}
}
diff --git a/packages/woocommerce/src/ProductData/ProductDataTab.php b/packages/woocommerce/src/ProductData/ProductDataTab.php
index b3329031..147c8162 100644
--- a/packages/woocommerce/src/ProductData/ProductDataTab.php
+++ b/packages/woocommerce/src/ProductData/ProductDataTab.php
@@ -218,11 +218,11 @@ protected function get_groups( array $tab, \WC_Product $product ): array {
* @param list $groups The tab's groups.
* @param \WC_Product $product The product being edited or saved.
*/
- return apply_filters( "{$this->id}/product_data_tab/groups", $tab['groups'], $product );
+ return apply_filters( "$this->id/product_data_tab/groups", $tab['groups'], $product );
}
/**
- * Prints the field's row with its stored value, through WooCommerce's function for its type, or through Forms::render_control() for a render callable, radios or checkboxes.
+ * Prints the field's row in WooCommerce's product data markup, with its stored value.
*
* @since 2.0.0
* @version 2.0.0
@@ -252,7 +252,7 @@ protected static function render_field( array $field, \WC_Product $product ): vo
'desc' => $field['description'] ?? '',
) + $field;
$group = Forms::is_group( $field );
- echo $group ? '