From 0731eabe310b25d3b8595d64b29ec6b0b8d7ca1f Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:42:05 +0100 Subject: [PATCH 01/19] Update packages The template locked the framework from before its plugin identity and slug-rooted hooks, and WooCommerce 11.2, while WooCommerce 11.3.0 is the newest stable. The Composer lock moves the framework to its eb1473c split, WooCommerce and its stubs to 11.3.0, PHPUnit to 13.4.7 and phpstorm-stubs to 2026.3; the npm lock takes Playwright 1.65.0 and the other releases past npm's seven-day cooldown, keeping the two transitive packages whose newer versions the cooldown hides. The entry's WooCommerce headers and constraints rise to 11.3 (X ruling 374). The framework move forces these changes: the composition root builds one PluginIdentity, hands it to the logger, the scheduler, the boot boundary, the WooCommerce check, the migration runner and the notices, and defines it in the container; uninstall.php builds its own after the engine's check; the runner derives dws_tpl_revision itself, so the revision constant goes and the footprint names its hook, dws_plugin_template/migration/run; the tests, the safeguard recipe and the architecture document read the framework's hooks under the slug; and the fill-in sweeps that root and refuses it as a leftover. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 4 +- composer.json | 4 +- composer.lock | 121 +++++----- docs/ARCHITECTURE.md | 4 +- docs/recipes.md | 14 +- dws-plugin-template.php | 4 +- includes/footprint.php | 2 +- includes/options.php | 10 - package-lock.json | 342 ++++++++++++++------------- src/Plugin.php | 34 ++- tests/Integration/MigrationsTest.php | 9 +- tests/Integration/MultisiteTest.php | 2 +- tests/Integration/PluginBootTest.php | 15 +- tests/Integration/UninstallTest.php | 12 +- tests/Unit/FootprintTest.php | 4 +- uninstall.php | 6 +- 16 files changed, 313 insertions(+), 274 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index e3de119..c107068 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -38,6 +38,7 @@ name_breakers="['\\\$/<>]" [[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.' export SCREAMING_PREFIX="${PREFIX^^}" +export HOOK_ROOT="${SLUG//-/_}" export TESTS_PORT=$(( PORT + 1 )) export MULTISITE_PORT=$(( PORT + 2 )) export REPO_NAME="${REPO#*/}" @@ -63,6 +64,7 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold. s{ahegyes/wordpress-plugin-template}{$ENV{REPO}}g; s/^# wordpress-plugin-template$/# $ENV{REPO_NAME}/; s/dws-plugin-template/$ENV{SLUG}/g; + s/dws_plugin_template/$ENV{HOOK_ROOT}/g; s/dws_tpl/$ENV{PREFIX}/g; s/DWS_TPL/$ENV{SCREAMING_PREFIX}/g; s/PluginTemplate/$ENV{NS}/g; @@ -104,7 +106,7 @@ npm ci --ignore-scripts --no-audit --no-fund node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/EndToEnd}/**/*.{js,jsx,ts,tsx}' status=0 -if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plugin Template|wordpress-plugin-template' -- . ':!.github/scripts/fill-in-scaffold.sh'; then +if git grep -n -I -E 'dws-plugin-template|dws_plugin_template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plugin Template|wordpress-plugin-template' -- . ':!.github/scripts/fill-in-scaffold.sh'; then echo "::error::The fill-in left the template's names above." status=1 fi diff --git a/composer.json b/composer.json index aa935cc..66a1697 100644 --- a/composer.json +++ b/composer.json @@ -58,9 +58,9 @@ "johnpbloch/wordpress-core": "^7.1", "laravel/serializable-closure": "^2.2", "php-di/php-di": "^7.1.1", - "php-stubs/woocommerce-stubs": "^11.2", + "php-stubs/woocommerce-stubs": "^11.3", "phpunit/phpunit": "^13", - "wp-plugin/woocommerce": "^11.2" + "wp-plugin/woocommerce": "^11.3" }, "minimum-stability": "dev", diff --git a/composer.lock b/composer.lock index 2de0372..c2b4326 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "131557a0ae2dc46ebbd539b11c6d2992", + "content-hash": "d1dead904139312560fe0aa9c50af89f", "packages": [], "packages-dev": [ { @@ -154,12 +154,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-bootstrap.git", - "reference": "02dc34c2641643e48cda5364008c0bd157832f1a" + "reference": "540d66c264eb1e2e08859a2e58bae97413d9e434" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-bootstrap/zipball/02dc34c2641643e48cda5364008c0bd157832f1a", - "reference": "02dc34c2641643e48cda5364008c0bd157832f1a", + "url": "https://api.github.com/repos/ahegyes/wp-framework-bootstrap/zipball/540d66c264eb1e2e08859a2e58bae97413d9e434", + "reference": "540d66c264eb1e2e08859a2e58bae97413d9e434", "shasum": "" }, "require": { @@ -181,7 +181,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-bootstrap/tree/trunk" }, - "time": "2026-11-10T11:50:48+00:00" + "time": "2026-11-12T18:58:26+00:00" }, { "name": "ahegyes/wp-framework-core", @@ -189,12 +189,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-core.git", - "reference": "e002d7760e8452fe9c583571d533e97d60317e28" + "reference": "6f191fb51f2887ce704a2c0248d9881839bea6e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-core/zipball/e002d7760e8452fe9c583571d533e97d60317e28", - "reference": "e002d7760e8452fe9c583571d533e97d60317e28", + "url": "https://api.github.com/repos/ahegyes/wp-framework-core/zipball/6f191fb51f2887ce704a2c0248d9881839bea6e5", + "reference": "6f191fb51f2887ce704a2c0248d9881839bea6e5", "shasum": "" }, "require": { @@ -229,7 +229,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-core/tree/trunk" }, - "time": "2026-11-10T11:50:49+00:00" + "time": "2026-11-12T22:21:17+00:00" }, { "name": "ahegyes/wp-framework-settings", @@ -237,12 +237,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-settings.git", - "reference": "2a5de7c03cdb62804e5d67972d0d9b9d72f79900" + "reference": "1aff9aa8c65550bb744950ed7bcd2362f37ffae0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-settings/zipball/2a5de7c03cdb62804e5d67972d0d9b9d72f79900", - "reference": "2a5de7c03cdb62804e5d67972d0d9b9d72f79900", + "url": "https://api.github.com/repos/ahegyes/wp-framework-settings/zipball/1aff9aa8c65550bb744950ed7bcd2362f37ffae0", + "reference": "1aff9aa8c65550bb744950ed7bcd2362f37ffae0", "shasum": "" }, "require": { @@ -271,7 +271,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-settings/tree/trunk" }, - "time": "2026-11-10T11:50:49+00:00" + "time": "2026-11-12T20:06:51+00:00" }, { "name": "ahegyes/wp-framework-shared", @@ -318,12 +318,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wp-framework-woocommerce.git", - "reference": "382115b0bd7711db6eb284d3167c0046d05bd171" + "reference": "01df7fb0739cdee77b356d7af6b085a8da5711bb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wp-framework-woocommerce/zipball/382115b0bd7711db6eb284d3167c0046d05bd171", - "reference": "382115b0bd7711db6eb284d3167c0046d05bd171", + "url": "https://api.github.com/repos/ahegyes/wp-framework-woocommerce/zipball/01df7fb0739cdee77b356d7af6b085a8da5711bb", + "reference": "01df7fb0739cdee77b356d7af6b085a8da5711bb", "shasum": "" }, "require": { @@ -337,7 +337,7 @@ "dev-trunk": "2.x-dev" }, "requires-wp": "7.1", - "requires-wc": "11.2", + "requires-wc": "11.3", "requires-plugins": [ "woocommerce" ], @@ -359,7 +359,7 @@ "support": { "source": "https://github.com/ahegyes/wp-framework-woocommerce/tree/trunk" }, - "time": "2026-11-10T11:50:49+00:00" + "time": "2026-11-12T20:06:51+00:00" }, { "name": "automattic/jetpack-changelogger", @@ -1063,23 +1063,23 @@ }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2026.2", + "version": "v2026.3", "source": { "type": "git", - "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "url": "https://github.com/JetBrains/phpstorm-stubs.git", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/912ae721089fc48534d3a5795997ee8a2802b8cd", + "reference": "912ae721089fc48534d3a5795997ee8a2802b8cd", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "type": "library", "autoload": { @@ -1103,8 +1103,11 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00", - "published-time": "2026-07-22T11:41:13+00:00" + "support": { + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2026.3" + }, + "time": "2026-10-19T10:54:17+00:00", + "published-time": "2026-11-11T13:34:29+00:00" }, { "name": "johnbillion/wp-compat", @@ -1664,16 +1667,16 @@ }, { "name": "php-stubs/woocommerce-stubs", - "version": "v11.2.2", + "version": "v11.3.0", "source": { "type": "git", "url": "https://github.com/php-stubs/woocommerce-stubs.git", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639" + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/d5c232c183c09689d75c177b01bef60e6dd53639", - "reference": "d5c232c183c09689d75c177b01bef60e6dd53639", + "url": "https://api.github.com/repos/php-stubs/woocommerce-stubs/zipball/a897f9ad0552482e9d50d3937daab20dfb97265c", + "reference": "a897f9ad0552482e9d50d3937daab20dfb97265c", "shasum": "" }, "require": { @@ -1702,10 +1705,10 @@ ], "support": { "issues": "https://github.com/php-stubs/woocommerce-stubs/issues", - "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.2.2" + "source": "https://github.com/php-stubs/woocommerce-stubs/tree/v11.3.0" }, - "time": "2026-11-04T21:47:17+00:00", - "published-time": "2026-11-04T21:48:23+00:00" + "time": "2026-11-12T00:45:07+00:00", + "published-time": "2026-11-12T00:47:40+00:00" }, { "name": "php-stubs/wordpress-stubs", @@ -2776,16 +2779,16 @@ }, { "name": "phpunit/phpunit", - "version": "13.4.6", + "version": "13.4.7", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b" + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/4821376960d0e1e5a7b7b700955ebcab33ecd71b", - "reference": "4821376960d0e1e5a7b7b700955ebcab33ecd71b", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e6458d44279e3b4fc492d4c41fe8e677efed7a2c", + "reference": "e6458d44279e3b4fc492d4c41fe8e677efed7a2c", "shasum": "" }, "require": { @@ -2799,7 +2802,7 @@ "phar-io/manifest": "^2.0.4", "phar-io/version": "^3.2.1", "php": ">=8.4.1", - "phpunit/php-code-coverage": "^14.5.0", + "phpunit/php-code-coverage": "^14.5.2", "phpunit/php-file-iterator": "^7.0.2", "phpunit/php-invoker": "^7.0.0", "phpunit/php-text-template": "^6.0.1", @@ -2815,7 +2818,7 @@ "sebastian/object-enumerator": "^8.1.0", "sebastian/recursion-context": "^8.0.1", "sebastian/type": "^7.0.2", - "sebastian/version": "^7.0.1", + "sebastian/version": "^7.1.0", "staabm/side-effects-detector": "^1.0.5" }, "bin": [ @@ -2856,7 +2859,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.6" + "source": "https://github.com/sebastianbergmann/phpunit/tree/13.4.7" }, "funding": [ { @@ -2864,8 +2867,8 @@ "type": "other" } ], - "time": "2026-11-02T07:12:28+00:00", - "published-time": "2026-11-02T07:13:08+00:00" + "time": "2026-11-13T11:10:10+00:00", + "published-time": "2026-11-13T11:11:23+00:00" }, { "name": "psr/clock", @@ -3130,12 +3133,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "d230c24b068602cc16c3c73be19f75f0c74afb32" + "reference": "a874c13980f04137c4adbcf86ae2cf1e7a74edb1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/d230c24b068602cc16c3c73be19f75f0c74afb32", - "reference": "d230c24b068602cc16c3c73be19f75f0c74afb32", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/a874c13980f04137c4adbcf86ae2cf1e7a74edb1", + "reference": "a874c13980f04137c4adbcf86ae2cf1e7a74edb1", "shasum": "" }, "conflict": { @@ -3217,6 +3220,7 @@ "barzahlen/barzahlen-php": "<2.0.1", "baserproject/basercms": "<=5.2.2", "bassjobsen/bootstrap-3-typeahead": ">4.0.2", + "bazaarvoice/bazaarvoice-magento2-ext": ">=8,<9.1.11", "bbpress/bbpress": "<2.6.5", "bcit-ci/codeigniter": "<3.1.3", "bcosca/fatfree": "<3.7.2", @@ -3270,7 +3274,7 @@ "clickstorm/cs-seo": ">=6,<6.8|>=7,<7.5|>=8,<8.4|>=9,<9.3", "co-stack/fal_sftp": "<0.2.6", "cockpit-hq/cockpit": "<=2.14", - "code16/sharp": "<9.22.5", + "code16/sharp": "<9.23|>=10.0.0.0-beta1,<10.0.0.0-beta4", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.10", "codeigniter4/framework": "<4.7.4", @@ -3288,9 +3292,9 @@ "contao-components/colorbox": ">=1,<1.6.4.3-dev", "contao-components/mediaelement": ">=2.14.2,<2.21.1", "contao/comments-bundle": ">=2,<5.3.50|>=5.4,<5.7.12", - "contao/contao": ">=3,<3.5.37|>=4,<5.3.50|>=5.4,<5.7.12", + "contao/contao": ">=3,<3.5.37|>=4,<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/core": "<3.5.39", - "contao/core-bundle": "<5.3.50|>=5.4,<5.7.12", + "contao/core-bundle": "<5.3.54|>=5.4,<5.7.17|>=6,<6.0.6", "contao/listing-bundle": ">=3,<=3.5.30|>=4,<4.4.8", "contao/managed-edition": "<=1.5", "contao/newsletter-bundle": ">=5,<5.3.50|>=5.4,<5.7.12", @@ -3500,6 +3504,7 @@ "gp247/core": "<1.1.24", "gree/jose": "<2.2.1", "gregwar/rst": "<1.0.3", + "grpc/grpc": "<1.84.1", "grumpydictator/firefly-iii": "<=6.6.2", "gugoan/economizzer": "<=0.9.0.0-beta1", "guzzlehttp/guzzle": "<7.15.2|>=8,<8.0.1", @@ -3725,7 +3730,7 @@ "nategood/httpful": "<1", "neoan3-apps/template": "<1.1.1", "neorazorx/facturascripts": "<2022.04", - "neos/flow": ">=1,<1.0.4|>=1.1,<1.1.1|>=2,<2.0.1|>=2.3,<2.3.16|>=3,<3.0.12|>=3.1,<3.1.10|>=3.2,<3.2.13|>=3.3,<3.3.13|>=4,<4.0.6", + "neos/flow": "<8.3.22|>=8.4,<8.4.7|>=9,<9.0.10|>=9.1,<9.1.5|>=9.2,<9.2.3", "neos/form": ">=1.2,<4.3.3|>=5,<5.0.9|>=5.1,<5.1.3", "neos/media-browser": "<7.3.19|>=8,<8.0.16|>=8.1,<8.1.11|>=8.2,<8.2.11|>=8.3,<8.3.9", "neos/neos": ">=1.1,<1.1.3|>=1.2,<1.2.13|>=2,<2.0.4|>=2.3,<3.0.20|>=3.1,<3.1.18|>=3.2,<3.2.14|>=3.3,<5.3.10|>=7,<7.0.9|>=7.1,<7.1.7|>=7.2,<7.2.6|>=7.3,<7.3.4|>=8,<8.0.2", @@ -3821,7 +3826,7 @@ "phpoffice/phpexcel": "<=1.8.2", "phpoffice/phpspreadsheet": "<=1.30.6|>=2,<=2.1.18|>=2.2,<=2.4.7|>=3,<=3.10.7|>=4,<=5.9", "phppgadmin/phppgadmin": "<=7.13", - "phpseclib/phpseclib": "<3.0.57|>=4,<4.0.1", + "phpseclib/phpseclib": "<3.0.58|>=4,<4.0.3", "phpservermon/phpservermon": "<3.6", "phpsysinfo/phpsysinfo": "<=3.4.5", "phpunit/phpunit": "<8.5.52|>=9,<9.6.33|>=10,<10.5.62|>=11,<11.5.50|>=12,<12.5.8|>=12.5.21,<12.5.22|>=13.1.5,<13.1.6|>=13.4,<13.4.4", @@ -4298,8 +4303,8 @@ "type": "tidelift" } ], - "time": "2026-11-10T12:08:53+00:00", - "published-time": "2026-11-10T12:08:57+00:00" + "time": "2026-11-13T18:24:02+00:00", + "published-time": "2026-11-13T18:24:07+00:00" }, { "name": "sebastian/cli-parser", @@ -7306,15 +7311,15 @@ }, { "name": "wp-plugin/woocommerce", - "version": "11.2.2", + "version": "11.3.0", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/11.2.2" + "reference": "tags/11.3.0" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.11.2.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.11.3.0.zip" }, "require": { "composer/installers": "~1.0|~2.0" @@ -7333,8 +7338,8 @@ "issues": "https://wordpress.org/support/plugin/woocommerce", "source": "https://plugins.svn.wordpress.org/woocommerce" }, - "time": "2026-11-04T21:34:49+00:00", - "published-time": "2026-11-04T21:34:49+00:00" + "time": "2026-11-11T16:56:56+00:00", + "published-time": "2026-11-11T16:56:56+00:00" } ], "aliases": [], diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8b26044..ff712c0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -18,10 +18,10 @@ This document describes how the plugin loads, boots, stores its data and leaves `src/Plugin.php` is the composition root. Its constructor builds the logger, the scheduler and the framework's `PluginBoot`, which runs the composition and reports its outcome: - The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked. -- `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_tpl/boot/blocked` fires, and users who can activate plugins see why the plugin stays off. +- `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_plugin_template/boot/blocked` fires, and users who can activate plugins see why the plugin stays off. - The migration runner registers, and runs pending steps on `wp_loaded`. - The kernel builds each class in `Plugin::COMPONENTS` through a PHP-DI container and calls its `register_hooks()`; a composite adds its children, and a conditional component loads only when its static `should_load()` returns true. -- Once every component has registered, the boot is `booted` and `dws_tpl/boot/succeeded` fires. A throw anywhere in the composition makes it `failed`: `dws_tpl/boot/failed` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it. +- Once every component has registered, the boot is `booted` and `dws_plugin_template/boot/succeeded` fires. A throw anywhere in the composition makes it `failed`: `dws_plugin_template/boot/failed` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`; its post types, routes and migrations first run on the next request. diff --git a/docs/recipes.md b/docs/recipes.md index 900a09f..221fffb 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -17,7 +17,7 @@ These are the changes most plugins built from this template make, each as the fi ## A safeguard -A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the floor gate and the autoloader, so it calls only WordPress, what that file defines, and WooCommerce where the plugin requires it. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it. +A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the floor gate and the autoloader, so it calls only WordPress, what that file defines, and WooCommerce where the plugin requires it. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_plugin_template/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it. Replace `includes/safeguards.php` with the helper and the safeguard. The file runs on any runtime that parses the plugin, so it must parse on PHP 7.4 and call only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked only once WooCommerce has loaded, as shown after the file. `phpstan.safeguards.neon` checks the WordPress floor, and review the WooCommerce one: @@ -49,7 +49,7 @@ add_filter( 'map_meta_cap', 'dws_tpl_map_export_capability', 10, 2 ); * @return bool */ function dws_tpl_is_relaxed( $filtered ): bool { - return 0 < did_action( 'dws_tpl/boot/succeeded' ) && false === $filtered; + return 0 < did_action( 'dws_plugin_template/boot/succeeded' ) && false === $filtered; } /** @@ -116,7 +116,7 @@ try { } ``` -Add the safeguard's failure test as `tests/Integration/SafeguardsTest.php`. The plugin has booted when the Integration suite runs, so the last row takes `dws_tpl/boot/succeeded` back for its duration: +Add the safeguard's failure test as `tests/Integration/SafeguardsTest.php`. The plugin has booted when the Integration suite runs, so the last row takes `dws_plugin_template/boot/succeeded` back for its duration: ```php array( Plugin::PREFIX . '_' ), 'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ), 'network_options' => array( Plugin::PREFIX . '_network_notices' ), - 'cron_hooks' => array( DWS_TPL_OPTION_REVISION . '/migration/run' ), + 'cron_hooks' => array( 'dws_plugin_template/migration/run' ), 'as_groups' => array( Plugin::PREFIX ), ); } diff --git a/includes/options.php b/includes/options.php index 79b6dcd..46d95c8 100644 --- a/includes/options.php +++ b/includes/options.php @@ -50,16 +50,6 @@ */ const DWS_TPL_OPTION_GREETING_ORDERS = 'dws_tpl_greeting_orders'; -/** - * The option in which the migration runner records the revision the plugin's data has reached. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_OPTION_REVISION = 'dws_tpl_revision'; - /** * The value each option reads as while nothing valid is stored. * diff --git a/package-lock.json b/package-lock.json index 555cd90..167c4b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -185,21 +185,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.13.tgz", + "integrity": "sha512-j1nvOQCZYSgp7HLmisnCXFTpVvFODiijQ9QHkORfGKWH6YDtskTEeCJS7eV545GZ9UBzrq0e2xsOd5QQ5PjIqw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/generator": "^7.29.13", + "@babel/helper-compilation-targets": "^7.29.13", + "@babel/helper-module-transforms": "^7.29.13", + "@babel/helpers": "^7.29.13", + "@babel/parser": "^7.29.13", "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -226,13 +226,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", - "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.13.tgz", + "integrity": "sha512-T5FRo+RzVHvGjWOuF1Ljc44b+NXAOazodHkGUHp4I3zCkQ4/sNVJm56h1S278LMR0bY5xBhztGziSbli8zObIw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.12", + "@babel/parser": "^7.29.13", "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", @@ -256,9 +256,9 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.13.tgz", + "integrity": "sha512-8WxJ4/Vmes3ghacj8VOJy3NW8eyHiFY/4thU/LSBuBE470+igjR/j1ZTNn8fV5VdNrLx6iXUwig526UdOvfP0w==", "dev": true, "license": "MIT", "dependencies": { @@ -283,9 +283,9 @@ } }, "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", - "integrity": "sha512-ACxGoWMV866q/QlZHeCOaQZAR5QclKsIvkAmlDemqjm6+6Bynijs1mH068BzH2N2e+wSuDmBWY87fqKfvhi1Lw==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.13.tgz", + "integrity": "sha512-fo8/Fr44/DMV71FQjk/HA0Npmu3bo5DFoWTCW1dhUR8Vx3NFfAVeEy0TbcBpt4e+hd2QhdmWgmspHo/9iWj8RA==", "dev": true, "license": "MIT", "dependencies": { @@ -293,8 +293,8 @@ "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", - "@babel/traverse": "^7.29.12", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13", + "@babel/traverse": "^7.29.13", "semver": "^6.3.1" }, "engines": { @@ -398,15 +398,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.13.tgz", + "integrity": "sha512-/U96wOz+SCoZ7Da/8kjDGltcNY280JPQcRNPK0Hk3PTbdpaiBDQImtC6JsbVwb1HGMr3usZQU2WsWVjhYNmRzw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -475,23 +475,23 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", - "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.13.tgz", + "integrity": "sha512-Abm4rKIH0tmCayJLwcoeOkdzpYUphTbO52fHhyEBHn7S0R+ni4epNLI/8nq1L0DN2Bl0u5qmgWm2Og/z4fBb+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/traverse": "^7.29.13", + "@babel/types": "^7.29.11" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.29.11", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", - "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.13.tgz", + "integrity": "sha512-fWegr+WsEsiMVW4KUNdIIzzMTZSzNiqhy0//6+2MRi2d5NFNsL/AYwBQW8eBnRbTBVRnDVzjaJEUqKaCWjMMdQ==", "dev": true, "license": "MIT", "engines": { @@ -534,23 +534,23 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.10", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", - "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.13.tgz", + "integrity": "sha512-laoRpD/kO/iA4CDlgJbixHekg7oPvWALK0PQyK3Ob2I2njZRvVAD77RfWCIImtqWCq6KijoBoZK7bp/HlTWASQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.11" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", - "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.13.tgz", + "integrity": "sha512-fJM50YYjlTiBhgdkGmC1tBpv+FMAyo4annf2X6Aks+0yRlaiVkYW/3f07xAfVVkYIoZ9fFZp+mEThUFnuybhkQ==", "dev": true, "license": "MIT", "dependencies": { @@ -613,14 +613,14 @@ } }, "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz", - "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.13.tgz", + "integrity": "sha512-7RpEgFZq9xNRHsWwPLFQpjPOi0ZECE2QZWMBes+vgK7E02krjOfPiYtnHTIUF2nCDyeCJPvSAj0AP4NTVf4hsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -827,9 +827,9 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.12.tgz", - "integrity": "sha512-nEreZBofjfd6FG621xe9Rk3+tMGBXf1nAuFrR6NUGF67fkQbSQf3NrbRuhgWRn+zb0qcy9XVok1GVFI/t9wWVQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.13.tgz", + "integrity": "sha512-q7f5zYLtDLqO11ydQK1w2RqZ1m5kmcAlmgZlfXssKgqVyL8+HgDvWXMxvuEz8TuJfq1UNsY9sB6ABjjtQkN6wA==", "dev": true, "license": "MIT", "dependencies": { @@ -915,14 +915,14 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", - "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.13.tgz", + "integrity": "sha512-X2meXB39NOPPmmshSRJzyd50hX9uCzbscNQwVCjLCtq363PZIJFFxJIeZPpgAewC570vpdeGIHTcltwd0QUlJg==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/traverse": "^7.29.7" + "@babel/traverse": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -1047,14 +1047,14 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", - "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.13.tgz", + "integrity": "sha512-dFjiIw+xBYpgLhYDc1QFQOZmMX44se0YCmXUjPsm9SQEj9HaCJAjvVnK6/yK9iE4E1LaKU5fIAKVJPZWCbKwXw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.13" }, "engines": { "node": ">=6.9.0" @@ -1939,16 +1939,16 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", - "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", + "version": "7.29.13", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.13.tgz", + "integrity": "sha512-0ve0xMlFA73dgY1oyJRY6RoW+/bncdcOQqg6jjYcVfXBK90OdLXjm9R6lmRetdZVrPjXkKIoMVBqYFPjcAlZ4Q==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.12", + "@babel/generator": "^7.29.13", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.12", + "@babel/parser": "^7.29.13", "@babel/template": "^7.29.7", "@babel/types": "^7.29.11", "debug": "^4.3.1" @@ -3280,6 +3280,13 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/nonrecursive-glob-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz", + "integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -5687,13 +5694,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", - "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", + "integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.64.1" + "playwright": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -6370,15 +6377,15 @@ "license": "MIT" }, "node_modules/@sentry/bundler-plugins": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.10.0.tgz", - "integrity": "sha512-+ngJ485rIYll5uOhWSvSO+Vx4S24MGe5KcLKlUZghr+BXK+ib32uBOZNG294wGyeauLTDvdjncLEYwiaBnP/5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugins/-/bundler-plugins-11.11.0.tgz", + "integrity": "sha512-et1v8V74ARNOSQ+k4VG7PRiNJl2APGAzlRCiUt42izw8yD+X+E4oCe7wwHjohmv3zufvD1VntHDptSC0/9ChQw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", + "@sentry/core": "11.11.0", "dotenv": "^17.4.2", - "glob": "^13.0.6", + "glob": "^14.0.3", "magic-string": "~0.30.8 || ^1.0.0", "oxc-parser": "^0.152.0", "sentry": "^0.45.0", @@ -6417,9 +6424,9 @@ } }, "node_modules/@sentry/conventions": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.29.0.tgz", - "integrity": "sha512-kYq9/HJ/l6MYP/srIc20dy1W4mWyJfCF0OWFF+RhBGZwyr/wCmtabRFKyw7kqM+DTwZXQOjSTSZUcJhDowcInQ==", + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.30.0.tgz", + "integrity": "sha512-WTumd5A2iFmHTRubi6mc6gLHU8LswPQqoNaZu6T1cxiTUmy3WdEw0i8FAnVbcgxTlsumX5oh63/b4B9lY5wAPQ==", "dev": true, "license": "MIT", "engines": { @@ -6427,46 +6434,46 @@ } }, "node_modules/@sentry/core": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.10.0.tgz", - "integrity": "sha512-l/OtTo1v+TQgZtHG2Q5N4IX+gFdAE1tZ27K/P21UCzJglWk54TPEm+d9T2lulPqMK06kZcLwDgRAhE2GO4YS5w==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-11.11.0.tgz", + "integrity": "sha512-PRrcEdPrTYuHcbDa6DhJZR9Qg2d+2gjKVXiv2QeiTgxvhvXDxYiaVSXH0lldKTR099Er5TAEChiX7aEu4yNlUw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0" + "@sentry/conventions": "^0.30.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/node": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.10.0.tgz", - "integrity": "sha512-ZsndrSn1NjL9AslpUdxz/kYPcv/i+plVZoyPe1ivlr5XPUpocB8pUL5noYbIUl96Wd/yGvPBw6zqO8d69rRKQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-11.11.0.tgz", + "integrity": "sha512-6hbFui4AwP/1KGQD7wgvG0rjve4vgb9x+cBAaHV2N7YwZDZUoKdVaJr/S4PYByuI6UPR8xtwGluFlMFXOScz+Q==", "dev": true, "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/bundler-plugins": "11.10.0", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0", - "@sentry/opentelemetry": "11.10.0", - "@sentry/server-runtime-injection": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/bundler-plugins": "11.11.0", + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0", + "@sentry/opentelemetry": "11.11.0", + "@sentry/server-runtime-injection": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/opentelemetry": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.10.0.tgz", - "integrity": "sha512-YMV4ZgaRlW7QPCcMScVpSBvYOdvDlY+alqRT3KOTcmPKFZtf8O2hXHWFhl/M33kKDEfqOk2XtEVeHs3mZFOCaw==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-11.11.0.tgz", + "integrity": "sha512-L1lXAF1hNUSOTLG8NLkBaopvWWpXqlYgQ++cfWr8Gw4wdmReNp9ec533bEj4r6ZJ6PvIYNdIVNI22GdvmbP6xg==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -6476,29 +6483,29 @@ } }, "node_modules/@sentry/server-runtime-injection": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.10.0.tgz", - "integrity": "sha512-1nc+HNPldrPEov16sL5m2cOLVgNJ+FsR7QtaWGOOv8qYFJkSp95lyc2qzLnaG2L72UzSMOv10peGRioAKrrZQQ==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-runtime-injection/-/server-runtime-injection-11.11.0.tgz", + "integrity": "sha512-nHHWfOYi2sB7db9Hjr3i/H66e1/YK9WMlBx3MSI6p/Ha66BGHF3vea/+gILjPz21viQclg1xq37mj8k9KLkGJw==", "dev": true, "license": "MIT", "dependencies": { - "@sentry/core": "11.10.0", - "@sentry/server-utils": "11.10.0" + "@sentry/core": "11.11.0", + "@sentry/server-utils": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" } }, "node_modules/@sentry/server-utils": { - "version": "11.10.0", - "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.10.0.tgz", - "integrity": "sha512-m/MVCvPzQJJZZCTFlVPCrTuoncT7SR80+QKg1K7ul25AsL0byABr5KsHCbXVpOYXLVz04Omk+pOOR5Cv2IAlrA==", + "version": "11.11.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-11.11.0.tgz", + "integrity": "sha512-t1lx9Zw+zhRre0ko+F3CsucG2BqB/7KOZmG1W+Wv/fSvYSnT06vg7WhIvmvH+df7BSFO+cFGBpkxwQWGq3zj+w==", "dev": true, "license": "MIT", "dependencies": { "@opentelemetry/api": "^1.9.1", - "@sentry/conventions": "^0.29.0", - "@sentry/core": "11.10.0" + "@sentry/conventions": "^0.30.0", + "@sentry/core": "11.11.0" }, "engines": { "node": ">=20.19.0 <22.0.0 || >=22.12.0 <23.0.0 || >=23.2.0" @@ -11211,9 +11218,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001820", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", - "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", + "version": "1.0.30001823", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001823.tgz", + "integrity": "sha512-TQ5s/djDjNk1axreUW+4rULH+BmdCMLiJkhaXnO5wqivG7tITkSUj4ItIdzqdZHKaXcHAV8dhH3JxOM7/7bwqw==", "dev": true, "funding": [ { @@ -11383,9 +11390,9 @@ } }, "node_modules/chrome-launcher": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.2.tgz", - "integrity": "sha512-sI90u5qf3854bTrWsQMMdsQfSjjMbeSn7QtxVb9zrikf+QgMfHQ71KepHW7ZfixQKrDlAFKb+qnVlSR+xepBzw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-1.2.3.tgz", + "integrity": "sha512-IpQuNUYnpXXq3VBRJInM7zTZDiAp/cu+zy0X74NjyQtFcZIQBbI64i0PYQO25wNwgmgw2cPBYitVmFz53N11Rg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11660,9 +11667,9 @@ } }, "node_modules/comctx": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/comctx/-/comctx-1.7.6.tgz", - "integrity": "sha512-rDhDqh6qfmivZfVOGi4qs8RB3PnVtIAB5zLsWu4JqCoQ1p4IEJSB5pW2JvOhFwCMmCLiei9qpaIMxmVVQRhzyw==", + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/comctx/-/comctx-1.7.7.tgz", + "integrity": "sha512-ul2WAOR50T9IB9DixjZrUNZgXOjNuSeLnvUKIDLHzDOHc/uwBD7r7q7Xlb7YpI5M+yR4qbJQOblYYIbjQhy0VA==", "dev": true, "license": "MIT" }, @@ -12843,9 +12850,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "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==", + "version": "1.5.478", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.478.tgz", + "integrity": "sha512-pftG+ma5Lm8O6/p45bhYT6StBN5ziQymaRzmCZwFl8DwWHKPPD93FdOmP1aqftcepolQYvrcyTLa17kE8Yf+jg==", "dev": true, "license": "ISC" }, @@ -14801,15 +14808,15 @@ } }, "node_modules/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "version": "14.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-14.0.4.tgz", + "integrity": "sha512-mMiwNKoJZ4HAIRVJxotO6LsNRReA30gQwP9E2/p94tfLv+jygCNpKDjhhSL6OLY6LE371EZm5EAf7RpYuUqlsw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.2.2", + "minimatch": "^11.0.5", "minipass": "^7.1.3", - "path-scurry": "^2.0.2" + "path-scurry": "^3.0.0" }, "engines": { "node": "18 || 20 || >=22" @@ -14848,6 +14855,23 @@ "tslib": "2" } }, + "node_modules/glob/node_modules/minimatch": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz", + "integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/nonrecursive-glob-match": "^1.1.0", + "brace-expansion": "^5.0.12" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/global-modules": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz", @@ -15831,9 +15855,9 @@ } }, "node_modules/is-core-module": { - "version": "2.17.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", - "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.18.0.tgz", + "integrity": "sha512-qyax9fgaKOEC4qK8WZn7Q4IcoZC9RC1O1fFxRcRTq/oMMkWT/uXpqpXa12kzWR409zAt1drYN9N9BO26UmBCmw==", "dev": true, "license": "MIT", "dependencies": { @@ -17131,13 +17155,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.truncate": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz", - "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -17427,9 +17444,9 @@ } }, "node_modules/mediabunny": { - "version": "1.65.1", - "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.65.1.tgz", - "integrity": "sha512-SDyhXooCNd8TYKNLf9jC4o/0I0MTft1pYc5/k4ztkKbGEMvcbYXWrAri5SozEu7DHI8PsG9IfN9XwKmPOg/VkQ==", + "version": "1.65.2", + "resolved": "https://registry.npmjs.org/mediabunny/-/mediabunny-1.65.2.tgz", + "integrity": "sha512-goZpibvo/5hkc/56WVNm93Ini+o7K4y8aXA20c/BN1B+m3L5aHQa8MTvO+S1APg/YI8kMILtrX6QCqUtxLW7bA==", "dev": true, "license": "MPL-2.0", "workspaces": [ @@ -19610,9 +19627,9 @@ "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz", + "integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -19809,13 +19826,13 @@ } }, "node_modules/playwright": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", - "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", + "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.64.1" + "playwright-core": "1.65.0" }, "bin": { "playwright": "cli.js" @@ -19825,9 +19842,9 @@ } }, "node_modules/playwright-core": { - "version": "1.64.1", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", - "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "version": "1.65.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", + "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -21396,16 +21413,16 @@ } }, "node_modules/regexpu-core": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.3.tgz", - "integrity": "sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g==", + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.4.tgz", + "integrity": "sha512-VpdamrZ9bw4YmvMZfTA3Yg8IHU9Guvg21bonvgjC4l3KfBkOqCPJesHakz7cQeE4Vh4zz4r4Gy6igAcnJxZv9g==", "dev": true, "license": "MIT", "dependencies": { "regenerate": "^1.5.0", "regenerate-unicode-properties": "^10.3.0", "regjsgen": "^0.8.0", - "regjsparser": "^0.13.0", + "regjsparser": "^0.14.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.2.1" }, @@ -21421,9 +21438,9 @@ "license": "MIT" }, "node_modules/regjsparser": { - "version": "0.13.4", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.4.tgz", - "integrity": "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==", + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.14.1.tgz", + "integrity": "sha512-IeYboCLG1KBNRksrZQIZ1SV5KVWZiR0ikFokOcA0n63vLvlaFvCsfxN+fd4aXwL0KW3YD+ByRzIgo9ZAHdbldw==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -21505,14 +21522,14 @@ } }, "node_modules/resolve": { - "version": "1.22.13", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", - "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "version": "1.22.14", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.14.tgz", + "integrity": "sha512-1ZjYvR9sbkiwC5+9QHetqW5HmA2pHRMaoupD01t2HlYnV/vydC2zPwLixhsMieSWVZt0JJRg5LVIk0Ow1D/TrA==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "is-core-module": "^2.17.0", + "is-core-module": "^2.18.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -21937,9 +21954,9 @@ "license": "MIT" }, "node_modules/sass": { - "version": "1.107.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.107.1.tgz", - "integrity": "sha512-aJ1/LvxOOb04YhmiBjzZn3jVwdHKol+OVhoMV6fFl02fF9T3i+5Mg9l/CnzqtM+NX5Jn5o3lAbs/ZJivTCLHfQ==", + "version": "1.108.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.108.0.tgz", + "integrity": "sha512-9HwnefouIfwY+XuA1rS5xOhALH5vsoe3SzYICG3MEwpkMbE7tpuh5ytZFRXUKPYkxY7lKdIbqxl+DxQGo8jwQQ==", "dev": true, "license": "MIT", "dependencies": { @@ -22017,9 +22034,9 @@ "peer": true }, "node_modules/schema-utils": { - "version": "4.8.2", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.2.tgz", - "integrity": "sha512-iKlak3OFHF0dgD1nZRvgWQzG+P1TiEcMnLSBtIVBg937z5Jd6WE1c3SyZkXIyCfPHowL35yMsVvIjotm1c1qiw==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.8.3.tgz", + "integrity": "sha512-qIfo3f+lRNqnMPgpXSKvUhsT3pY5U+ppmgGr4j8LanFP3W/Ze+xj8DY/dPb5qVu3m6qFIMyMWKVRLNKxrNhxWg==", "dev": true, "license": "MIT", "dependencies": { @@ -22039,7 +22056,7 @@ "url": "https://opencollective.com/webpack" }, "peerDependencies": { - "webpack": "^5.112.0" + "webpack": "^5.0.0" }, "peerDependenciesMeta": { "webpack": { @@ -23627,14 +23644,13 @@ "license": "MIT" }, "node_modules/table": { - "version": "6.9.0", - "resolved": "https://registry.npmjs.org/table/-/table-6.9.0.tgz", - "integrity": "sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==", + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/table/-/table-6.10.1.tgz", + "integrity": "sha512-f05boz6UD5S70AJiUxqKOnDuE45mBIVagX490oQmcW38+Lq/H1s7CjIpFgJjR/kpV87L4lw/AN+14D2PktQJsQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "ajv": "^8.0.1", - "lodash.truncate": "^4.4.2", "slice-ansi": "^4.0.0", "string-width": "^4.2.3", "strip-ansi": "^6.0.1" diff --git a/src/Plugin.php b/src/Plugin.php index 3befb4b..58a7b22 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -5,6 +5,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginKernel; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Scheduling\Scheduler; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Scheduling\SchedulerInterface; @@ -25,7 +26,7 @@ final class Plugin { // region FIELDS AND CONSTANTS /** - * The plugin's slug, which is also its text domain and its log source. + * The plugin's slug, which is also its text domain, its log source and, in snake case, the root of its hook names. * * @since 1.0.0 * @version 1.0.0 @@ -35,7 +36,7 @@ final class Plugin { public const string SLUG = 'dws-plugin-template'; /** - * The prefix of the plugin's hook names, options, transients and scheduled jobs. + * The prefix of the names the plugin stores its data under, and its Action Scheduler group. * * @since 1.0.0 * @version 1.0.0 @@ -110,6 +111,16 @@ final class Plugin { */ protected readonly PsrLoggerInterface $logger; + /** + * The plugin's identity, which every framework class the root builds takes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var PluginIdentity + */ + protected readonly PluginIdentity $identity; + // endregion // region MAGIC METHODS @@ -121,9 +132,10 @@ final class Plugin { * @version 1.0.0 */ public function __construct() { - $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_TPL_DEBUG' ) && true === \constant( 'DWS_TPL_DEBUG' ) ? 'debug' : null ); - $this->scheduler = new Scheduler( self::PREFIX ); - $this->boundary = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( DWS_TPL_FILE, self::SLUG, self::PREFIX ); + $this->logger = new WooCommerceLogger( $this->identity, \defined( 'DWS_TPL_DEBUG' ) && true === \constant( 'DWS_TPL_DEBUG' ) ? 'debug' : null ); + $this->scheduler = new Scheduler( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // endregion @@ -182,14 +194,14 @@ public function deactivate(): void { */ protected function compose(): bool { $this->scheduler->register_hooks(); // Before the WooCommerce check, so the plugin's recurring WP-Cron events keep their schedules while it is blocked. - if ( ! WooCommerceRequirement::check( DWS_TPL_FILE ) ) { + if ( ! WooCommerceRequirement::check( $this->identity ) ) { return false; } - $this->migrations = new MigrationRunner( DWS_TPL_FILE, DWS_TPL_OPTION_REVISION, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; } @@ -200,17 +212,19 @@ protected function compose(): bool { * @since 1.0.0 * @version 1.0.0 * + * @param PluginIdentity $identity The plugin's identity. * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. * * @return array */ - protected function get_definitions( PsrLoggerInterface $logger, Scheduler $scheduler ): array { + protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface $logger, Scheduler $scheduler ): array { return array( + PluginIdentity::class => static fn (): PluginIdentity => $identity, 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 ), + AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), ); } diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php index 11742c5..aa57bcb 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -13,11 +14,13 @@ final class MigrationsTest extends TestCase { use RestoresOptionsTrait; + protected const string REVISION = Plugin::PREFIX . '_revision'; + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); #[After] protected function restore_the_revision(): void { - update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) ); + update_option( self::REVISION, \count( dws_tpl_get_migration_steps() ) ); } // The runner's time budget lasts for its request, and this process's bootstrap spends it when it migrates a new site, so the row runs as a request of its own. @@ -27,11 +30,11 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra foreach ( self::OPTIONS as $key ) { delete_option( $key ); } - update_option( DWS_TPL_OPTION_REVISION, 0 ); + update_option( self::REVISION, 0 ); dws_tpl_plugin()->migrations?->run(); - self::assertSame( 1, MigrationRunner::get_revision( DWS_TPL_OPTION_REVISION ) ); + self::assertSame( 1, MigrationRunner::get_revision( self::REVISION ) ); self::assertSame( 'no', get_option( DWS_TPL_OPTION_GREETING_ENABLED ) ); self::assertSame( '1', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); diff --git a/tests/Integration/MultisiteTest.php b/tests/Integration/MultisiteTest.php index cee4d34..d2efd57 100644 --- a/tests/Integration/MultisiteTest.php +++ b/tests/Integration/MultisiteTest.php @@ -23,7 +23,7 @@ public function test_the_plugin_boots_on_another_site_of_the_network_and_stores_ self::assertIsInt( $site_id ); try { - $request = self::request( 'echo did_action( "dws_tpl/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); + $request = self::request( 'echo did_action( "dws_plugin_template/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); self::assertSame( '1', $request['last'], $request['output'] ); self::assertSame( 'no', get_blog_option( $site_id, DWS_TPL_OPTION_DELETE_DATA ) ); diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php index 8dad5c6..0f69a9c 100644 --- a/tests/Integration/PluginBootTest.php +++ b/tests/Integration/PluginBootTest.php @@ -5,6 +5,7 @@ use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -15,6 +16,8 @@ final class PluginBootTest extends TestCase { use RunsRequestsTrait; + protected const string REVISION = Plugin::PREFIX . '_revision'; + public function test_the_plugin_boots_and_skips_the_greeting_while_its_gate_is_off(): void { $plugin = dws_tpl_plugin(); @@ -28,7 +31,7 @@ public function test_the_entry_loads_every_include_but_those_whose_names_start_w self::assertContains( $includes . 'index.php', \get_included_files() ); self::assertIsInt( \file_put_contents( $includes . '_skipped-include.php', "getValue( new Plugin() ); self::assertInstanceOf( WooCommerceLogger::class, $logger ); - self::assertSame( self::get_threshold( new WooCommerceLogger( Plugin::SLUG, 'debug' ) ), self::get_threshold( $logger ) ); + self::assertSame( self::get_threshold( new WooCommerceLogger( new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX ), 'debug' ) ), self::get_threshold( $logger ) ); } public function test_a_request_brings_the_stored_data_to_the_revision_of_the_code(): void { - update_option( DWS_TPL_OPTION_REVISION, 0 ); + update_option( self::REVISION, 0 ); try { - $request = self::request( 'echo ' . MigrationRunner::class . '::get_revision( "' . DWS_TPL_OPTION_REVISION . '" );' ); + $request = self::request( 'echo ' . MigrationRunner::class . '::get_revision( "' . self::REVISION . '" );' ); } finally { wp_cache_flush(); // The request wrote the revision behind this process's cache. - update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) ); + update_option( self::REVISION, \count( dws_tpl_get_migration_steps() ) ); } self::assertSame( 0, $request['status'], $request['output'] ); diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index 5288540..df7d6b0 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -15,6 +15,8 @@ final class UninstallTest extends TestCase { protected const int USER = 1; + protected const string REVISION = Plugin::PREFIX . '_revision'; + #[AfterClass] public static function forget_the_rows_the_uninstall_deleted(): void { wp_cache_flush(); // The uninstall ran in a child process, so this process still caches the rows it deleted, such as a setting the tab saved. @@ -47,7 +49,7 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram set_transient( "{$prefix}boot_failure_seeded", 1, HOUR_IN_SECONDS ); update_user_option( self::USER, "{$prefix}hidden_notices", array( 'seeded' ) ); update_user_meta( self::USER, "{$prefix}network_hidden_notices", array( 'seeded' ) ); - wp_schedule_single_event( \time() + HOUR_IN_SECONDS, "{$prefix}revision/migration/run" ); + wp_schedule_single_event( \time() + HOUR_IN_SECONDS, 'dws_plugin_template/migration/run' ); as_schedule_single_action( \time() + HOUR_IN_SECONDS, "{$prefix}seeded", array(), Plugin::PREFIX ); self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) ); @@ -59,7 +61,7 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram self::assertFalse( get_transient( "{$prefix}boot_failure_seeded" ) ); self::assertFalse( get_user_option( "{$prefix}hidden_notices", self::USER ) ); self::assertSame( '', get_user_meta( self::USER, "{$prefix}network_hidden_notices", true ) ); - self::assertFalse( wp_next_scheduled( "{$prefix}revision/migration/run" ) ); + self::assertFalse( wp_next_scheduled( 'dws_plugin_template/migration/run' ) ); self::assertSame( array(), as_get_scheduled_actions( @@ -98,7 +100,7 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an \define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_TPL_FILE ) ); update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); - $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . DWS_TPL_OPTION_REVISION . "'"; + $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . self::REVISION . "'"; add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_tpl_missing_table' : $query ); add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) ); $wpdb->suppress_errors(); @@ -107,12 +109,12 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an include \dirname( DWS_TPL_FILE ) . '/uninstall.php'; self::fail( 'The uninstall finished.' ); } catch ( \LogicException $died ) { - self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . DWS_TPL_OPTION_REVISION, $died->getMessage() ); + self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . self::REVISION, $died->getMessage() ); self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); } finally { remove_all_filters( 'query' ); delete_option( DWS_TPL_OPTION_DELETE_DATA ); - delete_option( DWS_TPL_OPTION_REVISION ); + delete_option( self::REVISION ); dws_tpl_plugin()->migrations?->run(); } } diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index b4175a0..e8f01d6 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -27,7 +27,7 @@ public function test_every_kind_is_one_the_uninstall_engine_removes(): void { public function test_every_entry_is_in_the_plugin_prefix_family(): void { $family = array( Plugin::PREFIX . '_', - Plugin::PREFIX . '/', // Hook names put a slash after the prefix. + \str_replace( '-', '_', Plugin::SLUG ) . '/', // Hook names root in the slug. '_' . Plugin::PREFIX . '_', // WordPress hides meta keys that start with an underscore. '_transient_' . Plugin::PREFIX . '_', '_transient_timeout_' . Plugin::PREFIX . '_', @@ -52,7 +52,7 @@ public function test_it_covers_what_the_framework_roots_store(): void { self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_site_prefixes'] ); self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_global_prefixes'] ); self::assertContains( $prefix . 'network_notices', dws_tpl_get_footprint()['network_options'] ); - self::assertContains( DWS_TPL_OPTION_REVISION . '/migration/run', dws_tpl_get_footprint()['cron_hooks'] ); + self::assertContains( 'dws_plugin_template/migration/run', dws_tpl_get_footprint()['cron_hooks'] ); self::assertContains( Plugin::PREFIX, dws_tpl_get_footprint()['as_groups'] ); } diff --git a/uninstall.php b/uninstall.php index a537cdb..e3dd2cb 100644 --- a/uninstall.php +++ b/uninstall.php @@ -8,7 +8,9 @@ * @package DeepWebSolutions\PluginTemplate */ +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes; @@ -34,6 +36,8 @@ if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); } + + $dws_tpl_identity = new PluginIdentity( $dws_tpl_file, Plugin::SLUG, Plugin::PREFIX ); } catch ( Throwable $error ) { // WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data. if ( defined( 'WP_CLI' ) && true === WP_CLI ) { @@ -46,7 +50,7 @@ try { Uninstaller::run( - $dws_tpl_file, + $dws_tpl_identity, $dws_tpl_footprint, array( 'consent' => array( 'option' => DWS_TPL_OPTION_DELETE_DATA ), From 23c525d301d43b22e0784e1afbb95e244b84a6ea Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:45:19 +0100 Subject: [PATCH 02/19] Check every directory's stub and reject stray request output The template held no check that every shipped directory carries the silence-is-golden stub, and its fresh-request helper ignored any output before a row's last line, so a warning or notice a request printed went unnoticed. The plugins wrote both first, in the form the template takes (X ruling 371). DirectoryStubsTest and RunsRequestsTrait are copied from the plugins unchanged but for the namespace. The rows whose fixtures provoke a diagnostic, a missing file's warning, the build error's log line or a boot listener's throw, name it through request_provoking(). WooCommerce queries two of its tables on a new network site before installing them, so MultisiteTest first sends that site a request without the plugin, as the plugins' row does (X ruling 385). Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/MultisiteTest.php | 5 ++++ tests/Integration/PartialDeployTest.php | 8 +++--- tests/Integration/PluginBootTest.php | 2 +- tests/Support/RunsRequestsTrait.php | 18 +++++++++++--- tests/Unit/DirectoryStubsTest.php | 33 +++++++++++++++++++++++++ 5 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 tests/Unit/DirectoryStubsTest.php diff --git a/tests/Integration/MultisiteTest.php b/tests/Integration/MultisiteTest.php index d2efd57..f2a70c0 100644 --- a/tests/Integration/MultisiteTest.php +++ b/tests/Integration/MultisiteTest.php @@ -23,6 +23,11 @@ public function test_the_plugin_boots_on_another_site_of_the_network_and_stores_ self::assertIsInt( $site_id ); try { + // WooCommerce queries two of its tables during a new site's first init, before its version check installs them, so a request without the plugin installs WooCommerce first. + $installed = self::request_provoking( 'WordPress database error', 'echo (int) function_exists( "dws_tpl_plugin" );', '--url=' . get_home_url( $site_id, '/' ), '--skip-plugins=' . Plugin::SLUG ); + self::assertSame( 0, $installed['status'], $installed['output'] ); + self::assertSame( '0', $installed['last'], $installed['output'] ); + $request = self::request( 'echo did_action( "dws_plugin_template/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); self::assertSame( '1', $request['last'], $request['output'] ); diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 7a22840..8084180 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -71,7 +71,7 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, " ' ' . \escapeshellarg( $flag ), $flags ) ); - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. - $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + return self::request_provoking( '', $php, ...$flags ); + } + + // A row whose fixture provokes a diagnostic names it, so that diagnostic and what follows it, such as a fatal error's trace, pass, while any output before it fails the row. + protected static function request_provoking( string $diagnostic, string $php, string ...$flags ): array { + \exec( self::command( $php, ...$flags ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + $provoked = '' === $diagnostic ? null : \array_find_key( $output, static fn ( string $line ): bool => \str_contains( $line, $diagnostic ) ); + + self::assertSame( array(), \array_slice( $output, 0, \is_int( $provoked ) ? $provoked : \max( 0, \count( $output ) - 1 ) ), \implode( "\n", $output ) ); return array( 'status' => $status, 'output' => \implode( "\n", $output ), 'last' => (string) \end( $output ), ); } + + protected static function command( string $php, string ...$flags ): string { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + return 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ); + } } diff --git a/tests/Unit/DirectoryStubsTest.php b/tests/Unit/DirectoryStubsTest.php new file mode 100644 index 0000000..ea7a3eb --- /dev/null +++ b/tests/Unit/DirectoryStubsTest.php @@ -0,0 +1,33 @@ + $file->isDir() && ! \str_starts_with( $file->getFilename(), '.' ) && ! \in_array( $file->getFilename(), self::SKIPPED, true ) + ), + \RecursiveIteratorIterator::SELF_FIRST + ); + $missing = array(); + foreach ( array( $root, ...\array_keys( \iterator_to_array( $directories ) ) ) as $directory ) { + $stub = "$directory/index.php"; + if ( ! \is_file( $stub ) || " Date: Fri, 13 Nov 2026 19:45:39 +0100 Subject: [PATCH 03/19] Hold Tested up to to the locked WordPress X ruling 373 had the template drop its entry's Tested up to as the plugins did, because Plugin Check refuses the header beside a readme.txt. The template has no readme.txt, so Plugin Check never reaches that check, while the bundled updater reads the header at the release tag for each update offer; without it, WordPress's Updates screen reads "Not tested" for every release (X ruling 407). The entry keeps the header, and an EntryHeadersTest row holds it to the locked WordPress core's major and minor, as the WC tested up to row does for WooCommerce. The wordpress.org recipe's first step deletes the header and its row along with Update URI. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 2 +- tests/Unit/EntryHeadersTest.php | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/recipes.md b/docs/recipes.md index 221fffb..35ee0c9 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -936,7 +936,7 @@ $restricted = apply_filters( 'dws_tpl/data_export/should_restrict', $restricted A plugin listed on wordpress.org gets its updates and its language packs from there, so it drops the GitHub updater's header and adds the directory's `readme.txt`. -1. In the entry file, delete the `Update URI` header, which turns the bundled updater inert, and the `load_plugin_textdomain()` line, since language packs load just in time. +1. In the entry file, delete the `Update URI` header, which turns the bundled updater inert, the `Tested up to` header, which only that updater reads and Plugin Check refuses beside a `readme.txt`, with `EntryHeadersTest`'s row for it, and the `load_plugin_textdomain()` line, since language packs load just in time. 2. Add `readme.txt`, whose `Stable tag` the release checks against the `Version` header, whose floors repeat the entry file's headers, and whose changelog block `composer changelog:write` fills: ```text diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php index 86d762a..91ca90f 100644 --- a/tests/Unit/EntryHeadersTest.php +++ b/tests/Unit/EntryHeadersTest.php @@ -18,6 +18,17 @@ public function test_wc_tested_up_to_names_the_locked_woocommerce(): void { self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); } + // The bundled updater offers each release as tested up to this header, since a plugin that ships through GitHub has no readme.txt. + public function test_tested_up_to_names_the_locked_wordpress(): void { + $lock = \json_decode( (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/composer.lock' ), true, 512, \JSON_THROW_ON_ERROR ); + $wordpress = \array_find( $lock['packages-dev'], static fn ( array $package ): bool => 'johnpbloch/wordpress-core' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); + + self::assertIsArray( $wordpress ); + self::assertSame( 1, \preg_match( '/^\s*\*\s*Tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $wordpress['version'] ), 0, 2 ) ), $header[1] ); + } + public function test_every_block_carries_the_entry_version(): void { $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); $blocks = \glob( \dirname( __DIR__, 2 ) . '/blocks/src/*/block.json' ); From f285640ed9f7330e65458de619a20d83bb390065 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:45:57 +0100 Subject: [PATCH 04/19] Root the template's own hooks in its slug The framework roots every hook it fires for a plugin in the plugin's slug, which survives a change of owner, while the template's recipes and its AGENTS.md still named the hooks a plugin fires itself under the dws_tpl storage prefix, and its README and the fill-in workflow's inputs gave the prefix as the root of every hook (X ruling 377). The recipes' safeguard filter, its relaxation and the hook-renaming example, and AGENTS.md's hook-name rule, read dws_plugin_template/, which the fill-in sweeps to the new plugin's slug in snake case. The README and the workflow's inputs name the slug as the hook root and the prefix as that of the globals and the stored data. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/fill-in-scaffold.yml | 4 ++-- AGENTS.md | 2 +- README.md | 4 ++-- docs/recipes.md | 12 ++++++------ 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index b1d81a1..859b87b 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -12,7 +12,7 @@ on: required: true type: string plugin-slug: - description: 'Kebab-case slug, also the text domain and the entry file name, such as "acme-orders"' + description: 'Kebab-case slug, also the text domain, the hook root and the entry file name, such as "acme-orders"' required: true type: string php-namespace: @@ -20,7 +20,7 @@ on: required: true type: string prefix: - description: 'Prefix of the global functions, constants, options and hooks, dws_ plus one segment, such as "dws_ao"' + description: 'Prefix of the global functions, constants and stored data, dws_ plus one segment, such as "dws_ao"' required: true type: string wp-env-port: diff --git a/AGENTS.md b/AGENTS.md index 7c6d01c..f0d043d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,7 +70,7 @@ The plugin's contract is the elements tagged `@api`, the hooks it fires, and its - Every PHP file opens with `:`, never `Co-Authored-By`. diff --git a/README.md b/README.md index a135e1d..dff5779 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Create the plugin's repository from this template with GitHub's **Use this templ | --- | --- | --- | | `plugin-name` | `Acme Orders for WooCommerce` | the display name in the plugin header, the strings and the docs | | `description` | `Acme Orders keeps every order a store takes in one place.` | the entry's `Description` header, `composer.json`'s and `package.json`'s `description` and the README's opening sentence; set the repository's GitHub description to the same sentence | -| `plugin-slug` | `acme-orders-for-woocommerce` | the text domain and the names of the entry file and the release zip | +| `plugin-slug` | `acme-orders-for-woocommerce` | the text domain, the root of the hook names in snake case, and the names of the entry file and the release zip | | `php-namespace` | `AcmeOrders` | the namespace segment after `DeepWebSolutions\` | -| `prefix` | `dws_aowc` | the prefix of every global function, constant, option and hook | +| `prefix` | `dws_aowc` | the prefix of the global functions and constants, and of the names the plugin stores its data under | | `wp-env-port` | `8831` | the development site's port; the tests and multisite sites take the next two | The workflow: diff --git a/docs/recipes.md b/docs/recipes.md index 35ee0c9..75d4db7 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -78,7 +78,7 @@ function dws_tpl_map_export_capability( array $caps, string $cap ): array { * * @param bool $restricted Whether the data export stays locked. */ - return dws_tpl_is_relaxed( apply_filters( 'dws_tpl/data_export/should_restrict', true ) ) ? array( 'manage_options' ) : array( 'do_not_allow' ); + return dws_tpl_is_relaxed( apply_filters( 'dws_plugin_template/data_export/should_restrict', true ) ) ? array( 'manage_options' ) : array( 'do_not_allow' ); } ``` @@ -108,11 +108,11 @@ function dws_tpl_add_woocommerce_safeguards(): void { A component relaxes the lock only around the operation that needs it, and removes the relaxation even when the operation throws: ```php -add_filter( 'dws_tpl/data_export/should_restrict', '__return_false' ); +add_filter( 'dws_plugin_template/data_export/should_restrict', '__return_false' ); try { $this->export(); // Checks current_user_can( 'dws_tpl_export_data' ). } finally { - remove_filter( 'dws_tpl/data_export/should_restrict', '__return_false' ); + remove_filter( 'dws_plugin_template/data_export/should_restrict', '__return_false' ); } ``` @@ -129,7 +129,7 @@ use PHPUnit\Framework\TestCase; #[CoversNothing] final class SafeguardsTest extends TestCase { - protected const string FILTER = 'dws_tpl/data_export/should_restrict'; + protected const string FILTER = 'dws_plugin_template/data_export/should_restrict'; public static function relaxations(): array { return array( @@ -913,7 +913,7 @@ A public hook's name is part of the plugin's contract, as its arguments, its opt 2. Fire the old name too, until the next major, through `do_action_deprecated()` or `apply_filters_deprecated()` with the release's version and the new name as its replacement. The old name goes first, so a filter's callbacks on the new name see the final value: ```php -$restricted = apply_filters_deprecated( 'dws_tpl/export/should_restrict', array( true ), '1.1.0', 'dws_tpl/data_export/should_restrict' ); +$restricted = apply_filters_deprecated( 'dws_plugin_template/export/should_restrict', array( true ), '1.1.0', 'dws_plugin_template/data_export/should_restrict' ); /** * Filters whether the data export stays locked. @@ -925,7 +925,7 @@ $restricted = apply_filters_deprecated( 'dws_tpl/export/should_restrict', array( * * @param bool $restricted Whether the data export stays locked. */ -$restricted = apply_filters( 'dws_tpl/data_export/should_restrict', $restricted ); +$restricted = apply_filters( 'dws_plugin_template/data_export/should_restrict', $restricted ); ``` 3. Add a changelog fragment with `composer changelog:add` that names the old and the new hook, with a minor significance and the Deprecated type. From be8aaeb7c10cb125a6c08e0ba14d7df6ed305490 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:48:32 +0100 Subject: [PATCH 05/19] Expect the safeguards in a class and one build catch The template's safeguards are an include file that registers named global callbacks, which any code can remove by name, and its entry reports an incomplete build through two global helpers, one of which logs the error once an hour, while functions.php catches a composition root that cannot be built (X rulings 393 and 394). PluginBootTest expects the entry to require Safeguards.php before the bootstrap package, and drops the boot-listener row, whose catch goes. PartialDeployTest finds the entry's notice among the admin notices' callbacks and checks, for a missing bootstrap package, blocks manifest, assets build or autoloader and for an include that throws, that nothing boots, that a plugin manager sees the notice and a visitor does not, and that no log line is printed; the log and deactivation rows go. SafeguardRecipeTest loads the recipe's Safeguards class under another name and removes its safeguard by the closure's class. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/PartialDeployTest.php | 99 ++++++----------------- tests/Integration/PluginBootTest.php | 15 +--- tests/Integration/SafeguardRecipeTest.php | 15 +++- 3 files changed, 39 insertions(+), 90 deletions(-) diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 8084180..cdb6001 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -6,6 +6,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -13,113 +14,65 @@ final class PartialDeployTest extends TestCase { use RunsRequestsTrait; - public function test_the_build_notice_says_the_features_stay_off_until_the_plugin_is_reinstalled(): void { - wp_set_current_user( 1 ); - \ob_start(); - try { - dws_tpl_render_build_notice(); - } finally { - $notice = (string) \ob_get_clean(); - wp_set_current_user( 0 ); - } - - $sentence = \Dom\HTMLDocument::createFromString( '' . $notice . '', \LIBXML_NOERROR )->querySelector( '.notice-error p' ); + protected const string NOTICE = 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.'; - self::assertSame( 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.', $sentence?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property. - } + // Finds the notice the entry hooks for an incomplete build, and prints whether the plugin booted and what an administrator and a visitor see of the notice. + protected const string PROBE = '$notice = null; foreach ( isset( $GLOBALS["wp_filter"]["all_admin_notices"] ) ? $GLOBALS["wp_filter"]["all_admin_notices"]->callbacks : array() as $callbacks ) { foreach ( $callbacks as $callback ) { if ( $callback["function"] instanceof Closure && DWS_TPL_FILE === new ReflectionFunction( $callback["function"] )->getFileName() ) { $notice = $callback["function"]; } } } $seen = array(); foreach ( array( 1, 0 ) as $user ) { wp_set_current_user( $user ); ob_start(); if ( $notice instanceof Closure ) { $notice(); } $seen[] = trim( wp_strip_all_tags( (string) ob_get_clean() ) ); } echo wp_json_encode( array( did_action( "dws_plugin_template/boot/succeeded" ), $seen ) );'; - public function test_a_missing_blocks_manifest_shows_the_build_notice_and_loads_nothing_built(): void { - $manifest = \dirname( DWS_TPL_FILE ) . '/blocks/build/blocks-manifest.php'; - \rename( $manifest, "$manifest.hidden" ); - try { - $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); - } finally { - \rename( "$manifest.hidden", $manifest ); - } - - self::assertSame( 0, $request['status'], $request['output'] ); - self::assertSame( '[false,true]', $request['last'] ); + public static function missing_build_files(): array { + return array( + 'bootstrap-package' => array( 'vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php', '' ), + 'blocks-manifest' => array( 'blocks/build/blocks-manifest.php', '' ), + 'assets-build' => array( 'assets/build/editor.asset.php', '' ), + 'autoloader' => array( 'vendor/autoload.php', 'Failed to open stream' ), + ); } - public function test_a_missing_assets_build_shows_the_build_notice_and_loads_nothing_built(): void { - $asset = \dirname( DWS_TPL_FILE ) . '/assets/build/editor.asset.php'; - \rename( $asset, "$asset.hidden" ); - try { - $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); - } finally { - \rename( "$asset.hidden", $asset ); - } + public function test_a_complete_build_boots_and_shows_no_build_notice(): void { + $request = self::request( self::PROBE ); self::assertSame( 0, $request['status'], $request['output'] ); - self::assertSame( '[false,true]', $request['last'] ); + self::assertSame( wp_json_encode( array( 1, array( '', '' ) ) ), $request['last'] ); } - public function test_a_missing_bootstrap_package_shows_the_build_notice_and_loads_nothing_built(): void { - $bootstrap = \dirname( DWS_TPL_FILE ) . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; - \rename( $bootstrap, "$bootstrap.hidden" ); + #[DataProvider( 'missing_build_files' )] + public function test_a_missing_build_file_shows_the_build_notice_to_plugin_managers_and_boots_nothing( string $file, string $diagnostic ): void { + $path = \dirname( DWS_TPL_FILE ) . "/$file"; + \rename( $path, "$path.hidden" ); try { - $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); + $request = self::request_provoking( $diagnostic, self::PROBE ); } finally { - \rename( "$bootstrap.hidden", $bootstrap ); + \rename( "$path.hidden", $path ); } self::assertSame( 0, $request['status'], $request['output'] ); - self::assertSame( '[false,true]', $request['last'] ); + self::assertSame( wp_json_encode( array( 0, array( self::NOTICE, '' ) ) ), $request['last'] ); } - public function test_an_include_that_throws_shows_the_build_notice_and_boots_nothing(): void { + public function test_an_include_that_throws_shows_the_build_notice_and_boots_nothing_without_a_log_line(): void { $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, "callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + if ( $callback['function'] instanceof \Closure && RecipeSafeguards::class === new \ReflectionFunction( $callback['function'] )->getClosureScopeClass()?->getName() ) { + remove_filter( 'map_meta_cap', $callback['function'], $priority ); + } + } + } } // The recipe's test class is loaded from its block, so PHPUnit never discovers it and this row runs each of its rows. From 6c584a296acf0e6022fbd7e7b4c0cbf63c0ff960 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:51:16 +0100 Subject: [PATCH 06/19] Move the safeguards into a class and catch an incomplete build once A named global safeguard callback can be removed with remove_filter(), which defeats a restriction meant to relax only through its own filter, and the entry's two build-error helpers, the hourly log line and functions.php's catches around the composition root added globals and code for failures the entry already sees (X rulings 393 and 394). Safeguards.php at the plugin root is a final class the entry requires by path before the floor gate and calls once, an empty slot whose recipe adds static-closure safeguards and the protected relaxation helper. The entry wraps the bootstrap, the floor gate, the build check, the autoloader and functions.php in one try, whose catch rethrows in local and development environments and otherwise hooks one static-closure notice. functions.php boots, declares the WooCommerce compatibility and unschedules on deactivation from static closures without catches, leaving the composition's failures to PluginBoot. includes/safeguards.php goes; the safeguards' PHPStan profile, Quality and the fill-in parse Safeguards.php on PHP 7.4 in its place, and the profile no longer ignores wp_get_environment_type(), which the catch guards with function_exists(). Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 4 +- .github/workflows/fill-in-scaffold.yml | 2 +- .github/workflows/quality.yml | 4 +- Safeguards.php | 41 ++++++++ docs/recipes.md | 125 ++++++++++++++----------- dws-plugin-template.php | 87 ++++++----------- functions.php | 76 ++++----------- includes/safeguards.php | 13 --- phpstan.dist.neon | 4 +- phpstan.safeguards.neon | 8 +- 10 files changed, 165 insertions(+), 199 deletions(-) create mode 100644 Safeguards.php delete mode 100644 includes/safeguards.php diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index c107068..289e45b 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -51,8 +51,8 @@ git mv --force README.scaffold.md README.md # The scaffold README becomes the plugin's README.md, which .distignore already excludes. perl -ni -e 'print unless /^README\.scaffold\.md$/' .distignore git rm --quiet includes/_disabled-example.php -# The template's recipe test loads the recipe's safeguard helper, which a plugin that applies the recipe declares -# itself, so only the recipe's own test stays. +# The template's recipe test loads the recipe's SafeguardsTest, which a plugin that applies the recipe declares +# itself, so only the plugin's own copy stays. git rm --quiet tests/Integration/SafeguardRecipeTest.php git mv dws-plugin-template.php "$SLUG.php" diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 859b87b..49a94f7 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -76,7 +76,7 @@ jobs: env: SLUG: ${{ inputs.plugin-slug }} run: | - for file in "$SLUG.php" uninstall.php includes/*.php; do + for file in "$SLUG.php" uninstall.php Safeguards.php; do php -l "$file" done diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 81af646..a75f850 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -57,7 +57,7 @@ jobs: uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c with: php-versions: '["7.4"]' - paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }} + paths: ${{ format('["{0}.php", "uninstall.php", "Safeguards.php"]', needs.slug.outputs.slug) }} scaffold: name: Scaffold rehearsal @@ -112,6 +112,6 @@ jobs: tools: none - name: Check the PHP 7.4 syntax run: | - for file in acme-orders.php uninstall.php includes/*.php; do + for file in acme-orders.php uninstall.php Safeguards.php; do php -l "$file" done diff --git a/Safeguards.php b/Safeguards.php new file mode 100644 index 0000000..d9a9e75 --- /dev/null +++ b/Safeguards.php @@ -0,0 +1,41 @@ +getMessage() ); - if ( false === get_transient( $key ) ) { - set_transient( $key, 1, HOUR_IN_SECONDS ); - error_log( 'DWS Plugin Template did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + require_once $dws_tpl_bootstrap; + Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them. + if ( ! Requirements::check_floors( __FILE__ ) ) { + return; } - add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); -} + if ( ! is_readable( __DIR__ . '/blocks/build/blocks-manifest.php' ) || ! is_readable( __DIR__ . '/assets/build/editor.asset.php' ) ) { // A checkout that skipped the script build would load without its blocks and scripts. + throw new RuntimeException( 'The blocks or the scripts are not built.' ); + } -/** - * Prints the notice that the plugin's build is incomplete to users who can activate plugins. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function dws_tpl_render_build_notice(): void { - if ( ! current_user_can( 'activate_plugins' ) ) { - return; + require_once __DIR__ . '/vendor/autoload.php'; + require_once __DIR__ . '/functions.php'; +} catch ( Throwable $error ) { // PHP throws an Error for a missing required file, so an incomplete build lands here. + if ( function_exists( 'wp_get_environment_type' ) && in_array( wp_get_environment_type(), array( 'local', 'development' ), true ) ) { + throw $error; } - printf( '

%s

', esc_html__( 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.', 'dws-plugin-template' ) ); + add_action( + 'all_admin_notices', + static function (): void { + if ( current_user_can( 'activate_plugins' ) ) { + printf( '

%s

', esc_html__( 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.', 'dws-plugin-template' ) ); + } + } + ); } diff --git a/functions.php b/functions.php index 819168e..b66dce1 100644 --- a/functions.php +++ b/functions.php @@ -1,6 +1,6 @@ boot(); + }, + 15 +); +add_action( + 'before_woocommerce_init', + static function (): void { + FeaturesUtil::declare_compatibility( 'custom_order_tables', DWS_TPL_FILE, false ); // Each WooCommerce feature flag is declared from evidence. + } +); +register_deactivation_hook( + DWS_TPL_FILE, + static function (): void { + dws_tpl_plugin()->deactivate(); + } +); /** * Returns the plugin's composition root, built on first use. @@ -46,55 +62,3 @@ function dws_tpl_plugin(): Plugin { return $plugin; } - -/** - * Boots the plugin, reporting a composition root that cannot be built as an incomplete build. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function dws_tpl_boot(): void { - try { - $plugin = dws_tpl_plugin(); - } catch ( Throwable $error ) { - dws_tpl_handle_build_error( $error ); - return; - } - - $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. -} - -/** - * Unschedules the plugin's jobs on the site it is deactivated on, reporting a composition root that cannot be built as an incomplete build. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function dws_tpl_deactivate(): void { - try { - $plugin = dws_tpl_plugin(); - } catch ( Throwable $error ) { - dws_tpl_handle_build_error( $error ); - return; - } - - $plugin->deactivate(); -} - -/** - * Declares that the plugin does not yet work with WooCommerce's order tables. - * - * Each WooCommerce feature flag is declared from evidence. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function dws_tpl_declare_compatibility(): void { - FeaturesUtil::declare_compatibility( 'custom_order_tables', DWS_TPL_FILE, false ); -} diff --git a/includes/safeguards.php b/includes/safeguards.php deleted file mode 100644 index a957332..0000000 --- a/includes/safeguards.php +++ /dev/null @@ -1,13 +0,0 @@ - Date: Fri, 13 Nov 2026 19:53:03 +0100 Subject: [PATCH 07/19] Expect the options, migrations and footprint in classes The template's option keys, defaults, typed readers, translated text and validator, its migration steps and its footprint are prefixed globals in includes/, which any code can call and come to rely on, and its string reader returns the greeting's translated default, mixing presentation into the data read (X rulings 391, 392 and 408). The tests read the keys, defaults and readers from Options, the steps from Migrations::get_steps() and the footprint from Plugin::FOOTPRINT, which FootprintTest checks against Options' constants. A missing greeting reads as an empty string, untranslated, while the account dashboard still prints the translated default. The settings field's placeholder is the greeting's default message, and the validator's rows run through the field's validate callable. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/DeactivationTest.php | 5 +- .../Greeting/AccountDashboardGreetingTest.php | 24 +++--- .../Greeting/GreetingFeatureTest.php | 11 +-- tests/Integration/MigrationsTest.php | 34 ++++---- tests/Integration/MultisiteTest.php | 7 +- tests/Integration/OptionsTest.php | 64 ++++----------- tests/Integration/PartialDeployTest.php | 9 ++- tests/Integration/PluginBootTest.php | 5 +- tests/Integration/SettingsTest.php | 77 ++++++++++++------- tests/Integration/UninstallTest.php | 17 ++-- tests/Unit/FootprintTest.php | 25 +++--- 11 files changed, 137 insertions(+), 141 deletions(-) diff --git a/tests/Integration/DeactivationTest.php b/tests/Integration/DeactivationTest.php index eb9383d..407de20 100644 --- a/tests/Integration/DeactivationTest.php +++ b/tests/Integration/DeactivationTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Scheduling\ScheduledHook; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; @@ -11,14 +12,14 @@ final class DeactivationTest extends TestCase { #[After] protected function unschedule_hooks(): void { - foreach ( dws_tpl_get_footprint()['cron_hooks'] as $hook ) { + foreach ( Plugin::FOOTPRINT['cron_hooks'] as $hook ) { dws_tpl_plugin()->scheduler->unschedule_hook( $hook ); } } public function test_deactivation_unschedules_every_hook_of_the_footprint(): void { $scheduler = dws_tpl_plugin()->scheduler; - $hooks = \array_map( static fn ( string $hook ): ScheduledHook => new ScheduledHook( $hook ), dws_tpl_get_footprint()['cron_hooks'] ); + $hooks = \array_map( static fn ( string $hook ): ScheduledHook => new ScheduledHook( $hook ), Plugin::FOOTPRINT['cron_hooks'] ); foreach ( $hooks as $hook ) { self::assertTrue( $scheduler->enqueue( $hook ) ); } diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index c800e29..00d7892 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; @@ -16,7 +17,7 @@ final class AccountDashboardGreetingTest extends TestCase { use OrderStorageTrait; use RestoresOptionsTrait; - protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const array OPTIONS = array( Options::GREETING, Options::GREETING_ORDERS ); protected const string TRANSLATION = 'Translated greeting'; @@ -29,8 +30,9 @@ public static function setUpBeforeClass(): void { public static function untouched_greetings(): array { return array( - 'unsaved' => array( null ), - 'empty' => array( '' ), + 'unsaved' => array( null ), + 'empty' => array( '' ), + 'not-a-string' => array( array( 'Hello' ) ), ); } @@ -130,8 +132,8 @@ public function test_the_greeting_style_enqueues_only_a_stylesheet_it_registered } public function test_a_customer_whose_orders_reach_the_set_number_is_greeted_as_text(): void { - update_option( DWS_TPL_OPTION_GREETING, 'Hi & welcome' ); - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); + update_option( Options::GREETING, 'Hi & welcome' ); + update_option( Options::GREETING_ORDERS, '2' ); $this->place_orders( 2 ); $notice = \Dom\HTMLDocument::createFromString( '' . $this->print() . '', \LIBXML_NOERROR )->querySelector( '[role="status"]' ); @@ -142,7 +144,7 @@ public function test_a_customer_whose_orders_reach_the_set_number_is_greeted_as_ } public function test_a_customer_below_the_set_number_is_not_greeted(): void { - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); + update_option( Options::GREETING_ORDERS, '2' ); $this->place_orders( 1 ); self::assertSame( '', $this->print() ); @@ -151,7 +153,7 @@ public function test_a_customer_below_the_set_number_is_not_greeted(): void { #[DataProvider( 'storages' )] public function test_paid_orders_count_towards_the_set_number_on_every_order_storage( bool $hpos, bool $sync ): void { $this->use_storage( $hpos, $sync ); - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); + update_option( Options::GREETING_ORDERS, '2' ); $this->place_orders( 1 ); $this->place_orders( 1, 'failed' ); @@ -164,7 +166,7 @@ public function test_paid_orders_count_towards_the_set_number_on_every_order_sto #[DataProvider( 'unpaid_statuses' )] public function test_orders_that_are_not_paid_do_not_count_towards_the_set_number( string $status ): void { - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); + update_option( Options::GREETING_ORDERS, '2' ); $this->place_orders( 1 ); $this->place_orders( 2, $status ); @@ -172,9 +174,9 @@ public function test_orders_that_are_not_paid_do_not_count_towards_the_set_numbe } #[DataProvider( 'untouched_greetings' )] - public function test_an_untouched_greeting_prints_its_translated_default( ?string $stored ): void { - \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING ) : update_option( DWS_TPL_OPTION_GREETING, $stored ); - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '1' ); + public function test_an_untouched_greeting_prints_its_translated_default( mixed $stored ): void { + \is_null( $stored ) ? delete_option( Options::GREETING ) : update_option( Options::GREETING, $stored ); + update_option( Options::GREETING_ORDERS, '1' ); $this->place_orders( 1 ); $translate = static fn (): string => self::TRANSLATION; add_filter( 'gettext_' . Plugin::SLUG, $translate ); diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 0bbabec..7fc4b09 100644 --- a/tests/Integration/Greeting/GreetingFeatureTest.php +++ b/tests/Integration/Greeting/GreetingFeatureTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; @@ -16,7 +17,7 @@ final class GreetingFeatureTest extends TestCase { use OrderStorageTrait; use RestoresOptionsTrait; - protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const array OPTIONS = array( Options::GREETING_ENABLED, Options::GREETING, Options::GREETING_ORDERS ); public static function stored_toggles(): array { return array( @@ -28,7 +29,7 @@ public static function stored_toggles(): array { #[DataProvider( 'stored_toggles' )] public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?string $stored, bool $expected ): void { - \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING_ENABLED ) : update_option( DWS_TPL_OPTION_GREETING_ENABLED, $stored ); + \is_null( $stored ) ? delete_option( Options::GREETING_ENABLED ) : update_option( Options::GREETING_ENABLED, $stored ); self::assertSame( $expected, GreetingFeature::should_load() ); } @@ -37,9 +38,9 @@ public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?strin public function test_with_the_greeting_switched_on_the_boot_greets_a_customer_on_the_account_dashboard(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; add_filter( 'pre_wp_mail', '__return_false' ); // A completed order emails its customer, and the mailer's stderr fails a row in a separate process. - update_option( DWS_TPL_OPTION_GREETING_ENABLED, 'yes' ); - update_option( DWS_TPL_OPTION_GREETING, 'Welcome back.' ); - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '1' ); + update_option( Options::GREETING_ENABLED, 'yes' ); + update_option( Options::GREETING, 'Welcome back.' ); + update_option( Options::GREETING_ORDERS, '1' ); $customer = wp_insert_user( array( 'user_login' => Plugin::PREFIX . '_customer_' . wp_generate_password( 8, false ), diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php index aa57bcb..ba69e02 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Migrations; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; @@ -16,11 +18,11 @@ final class MigrationsTest extends TestCase { protected const string REVISION = Plugin::PREFIX . '_revision'; - protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + protected const array OPTIONS = array( Options::GREETING_ENABLED, Options::GREETING, Options::GREETING_ORDERS, Options::DELETE_DATA ); #[After] protected function restore_the_revision(): void { - update_option( self::REVISION, \count( dws_tpl_get_migration_steps() ) ); + update_option( self::REVISION, \count( Migrations::get_steps() ) ); } // The runner's time budget lasts for its request, and this process's bootstrap spends it when it migrates a new site, so the row runs as a request of its own. @@ -35,35 +37,35 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra dws_tpl_plugin()->migrations?->run(); self::assertSame( 1, MigrationRunner::get_revision( self::REVISION ) ); - self::assertSame( 'no', get_option( DWS_TPL_OPTION_GREETING_ENABLED ) ); - self::assertSame( '1', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); - self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertFalse( get_option( DWS_TPL_OPTION_GREETING ) ); - foreach ( array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ) as $key ) { + self::assertSame( 'no', get_option( Options::GREETING_ENABLED ) ); + self::assertSame( '1', get_option( Options::GREETING_ORDERS ) ); + self::assertSame( 'no', get_option( Options::DELETE_DATA ) ); + self::assertFalse( get_option( Options::GREETING ) ); + foreach ( array( Options::GREETING_ENABLED, Options::GREETING_ORDERS, Options::DELETE_DATA ) as $key ) { self::assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the stored autoload flag. } } public function test_the_first_step_keeps_the_values_a_site_stored(): void { - update_option( DWS_TPL_OPTION_GREETING_ENABLED, 'yes' ); - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '5' ); + update_option( Options::GREETING_ENABLED, 'yes' ); + update_option( Options::GREETING_ORDERS, '5' ); - dws_tpl_add_settings(); + Migrations::get_steps()[0](); - self::assertSame( 'yes', get_option( DWS_TPL_OPTION_GREETING_ENABLED ) ); - self::assertSame( '5', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); + self::assertSame( 'yes', get_option( Options::GREETING_ENABLED ) ); + self::assertSame( '5', get_option( Options::GREETING_ORDERS ) ); } public function test_the_first_step_throws_when_a_default_cannot_be_stored(): void { global $wpdb; - delete_option( DWS_TPL_OPTION_DELETE_DATA ); - $fail = static fn ( string $query ): string => \str_contains( $query, "'" . DWS_TPL_OPTION_DELETE_DATA . "'" ) && \str_starts_with( \ltrim( $query ), 'INSERT' ) ? "SELECT * FROM {$wpdb->prefix}dws_tpl_missing" : $query; + delete_option( Options::DELETE_DATA ); + $fail = static fn ( string $query ): string => \str_contains( $query, "'" . Options::DELETE_DATA . "'" ) && \str_starts_with( \ltrim( $query ), 'INSERT' ) ? "SELECT * FROM {$wpdb->prefix}dws_tpl_missing" : $query; $suppressed = $wpdb->suppress_errors(); add_filter( 'query', $fail ); try { - $this->expectExceptionObject( new \RuntimeException( 'The option ' . DWS_TPL_OPTION_DELETE_DATA . ' could not be stored.' ) ); - dws_tpl_add_settings(); + $this->expectExceptionObject( new \RuntimeException( 'The option ' . Options::DELETE_DATA . ' could not be stored.' ) ); + Migrations::get_steps()[0](); } finally { remove_filter( 'query', $fail ); $wpdb->suppress_errors( $suppressed ); diff --git a/tests/Integration/MultisiteTest.php b/tests/Integration/MultisiteTest.php index f2a70c0..531a899 100644 --- a/tests/Integration/MultisiteTest.php +++ b/tests/Integration/MultisiteTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -31,9 +32,9 @@ public function test_the_plugin_boots_on_another_site_of_the_network_and_stores_ $request = self::request( 'echo did_action( "dws_plugin_template/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); self::assertSame( '1', $request['last'], $request['output'] ); - self::assertSame( 'no', get_blog_option( $site_id, DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertTrue( update_blog_option( $site_id, DWS_TPL_OPTION_DELETE_DATA, 'yes' ) ); - self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( 'no', get_blog_option( $site_id, Options::DELETE_DATA ) ); + self::assertTrue( update_blog_option( $site_id, Options::DELETE_DATA, 'yes' ) ); + self::assertSame( 'no', get_option( Options::DELETE_DATA ) ); } finally { wp_delete_site( $site_id ); } diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 85eb055..7177c1b 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -15,7 +16,7 @@ final class OptionsTest extends TestCase { protected const string KEY = Plugin::PREFIX . '_options_test'; - protected const array OPTIONS = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const array OPTIONS = array( Options::DELETE_DATA, Options::GREETING, Options::GREETING_ORDERS ); protected const string TRANSLATION = 'Translated greeting'; @@ -52,27 +53,6 @@ public static function stored_strings(): array { ); } - public static function untouched_strings(): array { - return array( - 'unsaved' => array( null ), - 'empty' => array( '' ), - 'not-a-string' => array( array( 'Hello' ) ), - ); - } - - public static function submitted_order_counts(): array { - return array( - 'one' => array( '1', '1' ), - 'padded' => array( ' 3 ', '3' ), - 'zero' => array( '0', null ), - 'negative' => array( '-3', null ), - 'decimal' => array( '2.5', null ), - 'word' => array( 'three', null ), - 'empty' => array( '', null ), - 'array' => array( array( '3' ), null ), - ); - } - #[After] protected function delete_the_test_option(): void { delete_option( self::KEY ); @@ -80,64 +60,46 @@ protected function delete_the_test_option(): void { #[DataProvider( 'stored_bools' )] public function test_a_bool_reads_the_stored_checkbox_value_or_false( mixed $stored, bool $expected ): void { - update_option( DWS_TPL_OPTION_DELETE_DATA, $stored ); + update_option( Options::DELETE_DATA, $stored ); - self::assertSame( $expected, dws_tpl_get_bool_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( $expected, Options::get_bool( Options::DELETE_DATA ) ); } public function test_a_missing_option_reads_its_default(): void { - delete_option( DWS_TPL_OPTION_DELETE_DATA ); + delete_option( Options::DELETE_DATA ); - self::assertSame( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ], dws_tpl_get_bool_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( Options::DEFAULTS[ Options::DELETE_DATA ], Options::get_bool( Options::DELETE_DATA ) ); } #[DataProvider( 'stored_ints' )] public function test_an_int_reads_a_whole_number_or_zero( mixed $stored, int $expected ): void { update_option( self::KEY, $stored ); - self::assertSame( $expected, dws_tpl_get_int_option( self::KEY ) ); + self::assertSame( $expected, Options::get_int( self::KEY ) ); } public function test_an_int_that_is_not_a_whole_number_reads_its_default(): void { - update_option( DWS_TPL_OPTION_GREETING_ORDERS, 'seven' ); + update_option( Options::GREETING_ORDERS, 'seven' ); - self::assertSame( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ], dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); + self::assertSame( Options::DEFAULTS[ Options::GREETING_ORDERS ], Options::get_int( Options::GREETING_ORDERS ) ); } #[DataProvider( 'stored_strings' )] public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $stored, string $expected ): void { update_option( self::KEY, $stored ); - self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); + self::assertSame( $expected, Options::get_string( self::KEY ) ); } - #[DataProvider( 'untouched_strings' )] - public function test_a_string_without_a_usable_stored_value_reads_its_translated_default( mixed $stored ): void { - \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING ) : update_option( DWS_TPL_OPTION_GREETING, $stored ); + public function test_a_string_without_a_stored_value_reads_as_empty_and_untranslated(): void { + delete_option( Options::GREETING ); $translate = static fn (): string => self::TRANSLATION; add_filter( 'gettext_' . Plugin::SLUG, $translate ); try { - self::assertSame( self::TRANSLATION, dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + self::assertSame( '', Options::get_string( Options::GREETING ) ); } finally { remove_filter( 'gettext_' . Plugin::SLUG, $translate ); } } - - public function test_a_stored_string_reads_over_its_translated_default(): void { - update_option( DWS_TPL_OPTION_GREETING, 'Hello' ); - - self::assertSame( 'Hello', dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); - } - - public function test_an_option_without_a_translated_default_has_no_text(): void { - self::assertSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING_ENABLED ) ); - } - - #[DataProvider( 'submitted_order_counts' )] - public function test_an_order_count_validates_as_a_whole_number_of_at_least_one( mixed $submitted, ?string $expected ): void { - $validated = dws_tpl_validate_greeting_orders( $submitted ); - - \is_null( $expected ) ? self::assertInstanceOf( \WP_Error::class, $validated ) : self::assertSame( $expected, $validated ); - } } diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index cdb6001..56b19b0 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; @@ -77,8 +78,8 @@ public function test_the_peer_getters_a_partial_deploy_defines_still_answer(): v #[RunInSeparateProcess] public function test_an_uninstall_whose_engine_is_missing_keeps_the_data_and_lets_the_web_delete_the_files(): void { - $consent = get_option( DWS_TPL_OPTION_DELETE_DATA ); - update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); + $consent = get_option( Options::DELETE_DATA ); + update_option( Options::DELETE_DATA, 'yes' ); $log = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-uninstall-' ); \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. \spl_autoload_register( @@ -93,11 +94,11 @@ static function ( string $class_name ): void { try { self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) ); - self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( 'yes', get_option( Options::DELETE_DATA ) ); self::assertStringContainsString( 'kept its data on uninstall', (string) \file_get_contents( $log ) ); } finally { \unlink( $log ); - false === $consent ? delete_option( DWS_TPL_OPTION_DELETE_DATA ) : update_option( DWS_TPL_OPTION_DELETE_DATA, $consent ); + false === $consent ? delete_option( Options::DELETE_DATA ) : update_option( Options::DELETE_DATA, $consent ); } } } diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php index f767abb..890d432 100644 --- a/tests/Integration/PluginBootTest.php +++ b/tests/Integration/PluginBootTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; +use DeepWebSolutions\PluginTemplate\Migrations; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; @@ -65,11 +66,11 @@ public function test_a_request_brings_the_stored_data_to_the_revision_of_the_cod $request = self::request( 'echo ' . MigrationRunner::class . '::get_revision( "' . self::REVISION . '" );' ); } finally { wp_cache_flush(); // The request wrote the revision behind this process's cache. - update_option( self::REVISION, \count( dws_tpl_get_migration_steps() ) ); + update_option( self::REVISION, \count( Migrations::get_steps() ) ); } self::assertSame( 0, $request['status'], $request['output'] ); - self::assertSame( (string) \count( dws_tpl_get_migration_steps() ), $request['last'] ); + self::assertSame( (string) \count( Migrations::get_steps() ), $request['last'] ); } protected static function get_threshold( WooCommerceLogger $logger ): mixed { diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index 66fb21c..4d5d4d7 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -2,7 +2,10 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Settings; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -14,7 +17,7 @@ final class SettingsTest extends TestCase { use RestoresOptionsTrait; - protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + protected const array OPTIONS = array( Options::GREETING_ENABLED, Options::GREETING, Options::GREETING_ORDERS, Options::DELETE_DATA ); protected static SettingsTab $tab; @@ -44,10 +47,10 @@ public static function invalid_order_counts(): array { public static function field_texts(): array { return array( - 'toggle' => array( DWS_TPL_OPTION_GREETING_ENABLED, 'Show the greeting', 'Greet returning customers' ), - 'message' => array( DWS_TPL_OPTION_GREETING, 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ), - 'threshold' => array( DWS_TPL_OPTION_GREETING_ORDERS, 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ), - 'consent' => array( DWS_TPL_OPTION_DELETE_DATA, 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ), + 'toggle' => array( Options::GREETING_ENABLED, 'Show the greeting', 'Greet returning customers' ), + 'message' => array( Options::GREETING, 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ), + 'threshold' => array( Options::GREETING_ORDERS, 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ), + 'consent' => array( Options::DELETE_DATA, 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ), ); } @@ -60,6 +63,19 @@ public static function whole_order_counts(): array { ); } + public static function submitted_order_counts(): array { + return array( + 'one' => array( '1', '1' ), + 'padded' => array( ' 3 ', '3' ), + 'zero' => array( '0', null ), + 'negative' => array( '-3', null ), + 'decimal' => array( '2.5', null ), + 'word' => array( 'three', null ), + 'empty' => array( '', null ), + 'array' => array( array( '3' ), null ), + ); + } + #[After] protected function forget_the_rejections(): void { new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); @@ -73,12 +89,12 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau $form = $this->render(); self::assertSame( 'DWS Plugin Template', self::$tab->get_label() ); - self::assertFalse( $form->getElementById( DWS_TPL_OPTION_GREETING_ENABLED )?->hasAttribute( 'checked' ) ); - self::assertSame( '', $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) ); - self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'placeholder' ) ); - self::assertSame( '1', $form->getElementById( DWS_TPL_OPTION_GREETING_ORDERS )?->getAttribute( 'value' ) ); - self::assertSame( '1', $form->getElementById( DWS_TPL_OPTION_GREETING_ORDERS )?->getAttribute( 'min' ) ); - self::assertFalse( $form->getElementById( DWS_TPL_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) ); + self::assertFalse( $form->getElementById( Options::GREETING_ENABLED )?->hasAttribute( 'checked' ) ); + self::assertSame( '', $form->getElementById( Options::GREETING )?->getAttribute( 'value' ) ); + self::assertSame( AccountDashboardGreeting::get_default_message(), $form->getElementById( Options::GREETING )?->getAttribute( 'placeholder' ) ); + self::assertSame( '1', $form->getElementById( Options::GREETING_ORDERS )?->getAttribute( 'value' ) ); + self::assertSame( '1', $form->getElementById( Options::GREETING_ORDERS )?->getAttribute( 'min' ) ); + self::assertFalse( $form->getElementById( Options::DELETE_DATA )?->hasAttribute( 'checked' ) ); } #[DataProvider( 'field_texts' )] @@ -90,43 +106,50 @@ public function test_the_tab_words_each_field_by_what_it_does( string $key, stri } public function test_a_save_that_leaves_the_message_untouched_stores_no_translated_text(): void { - delete_option( DWS_TPL_OPTION_GREETING ); + delete_option( Options::GREETING ); - $this->save( array( DWS_TPL_OPTION_GREETING => $this->render()->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) ) ); + $this->save( array( Options::GREETING => $this->render()->getElementById( Options::GREETING )?->getAttribute( 'value' ) ) ); - self::assertSame( '', get_option( DWS_TPL_OPTION_GREETING ) ); - self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + self::assertSame( '', get_option( Options::GREETING ) ); } public function test_the_uninstall_consent_round_trips_through_woocommerces_save(): void { - $this->save( array( DWS_TPL_OPTION_DELETE_DATA => '1' ) ); + $this->save( array( Options::DELETE_DATA => '1' ) ); - self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertTrue( dws_tpl_get_bool_option( DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertTrue( $this->render()->getElementById( DWS_TPL_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) ); + self::assertSame( 'yes', get_option( Options::DELETE_DATA ) ); + self::assertTrue( Options::get_bool( Options::DELETE_DATA ) ); + self::assertTrue( $this->render()->getElementById( Options::DELETE_DATA )?->hasAttribute( 'checked' ) ); $this->save( array() ); - self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertFalse( dws_tpl_get_bool_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( 'no', get_option( Options::DELETE_DATA ) ); + self::assertFalse( Options::get_bool( Options::DELETE_DATA ) ); } #[DataProvider( 'invalid_order_counts' )] public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_keeps_the_stored_count( string $posted ): void { - update_option( DWS_TPL_OPTION_GREETING_ORDERS, '4' ); + update_option( Options::GREETING_ORDERS, '4' ); - $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => $posted ) ); + $this->save( array( Options::GREETING_ORDERS => $posted ) ); - self::assertSame( '4', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); + self::assertSame( '4', get_option( Options::GREETING_ORDERS ) ); self::assertSame( array( 'Orders before the greeting was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.' ), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); } #[DataProvider( 'whole_order_counts' )] public function test_a_whole_order_count_of_at_least_one_is_stored_as_its_digits( string $posted ): void { - $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => $posted ) ); + $this->save( array( Options::GREETING_ORDERS => $posted ) ); + + self::assertSame( '3', get_option( Options::GREETING_ORDERS ) ); + self::assertSame( 3, Options::get_int( Options::GREETING_ORDERS ) ); + } + + #[DataProvider( 'submitted_order_counts' )] + public function test_an_order_count_validates_as_a_whole_number_of_at_least_one( mixed $submitted, ?string $expected ): void { + $field = \array_find( new Settings()->get_section_fields( Plugin::PREFIX, '' ), static fn ( array $field ): bool => Options::GREETING_ORDERS === $field['id'] ); + $validated = \is_array( $field ) && \is_callable( $field['validate'] ?? null ) ? $field['validate']( $submitted ) : null; - self::assertSame( '3', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); - self::assertSame( 3, dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); + \is_null( $expected ) ? self::assertInstanceOf( \WP_Error::class, $validated ) : self::assertSame( $expected, $validated ); } protected function render(): \Dom\HTMLDocument { diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index df7d6b0..ee6c1de 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\AfterClass; @@ -43,7 +44,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void #[RunInSeparateProcess] public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_framework_roots_store(): void { $prefix = Plugin::PREFIX . '_'; - update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); + update_option( Options::DELETE_DATA, 'yes' ); update_option( "{$prefix}notices", array( 'seeded' ) ); update_network_option( null, "{$prefix}network_notices", array( 'seeded' ) ); set_transient( "{$prefix}boot_failure_seeded", 1, HOUR_IN_SECONDS ); @@ -55,7 +56,7 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) ); wp_cache_flush(); - self::assertFalse( get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertFalse( get_option( Options::DELETE_DATA ) ); self::assertFalse( get_option( "{$prefix}notices" ) ); self::assertFalse( get_network_option( null, "{$prefix}network_notices" ) ); self::assertFalse( get_transient( "{$prefix}boot_failure_seeded" ) ); @@ -75,16 +76,16 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram // The other tests share the database, so the runner stores again what a new site starts with. dws_tpl_plugin()->migrations?->run(); - self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( 'no', get_option( Options::DELETE_DATA ) ); } #[RunInSeparateProcess] public function test_an_uninstall_in_a_request_that_never_loaded_the_plugin_removes_its_data(): void { - update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); + update_option( Options::DELETE_DATA, 'yes' ); try { // WordPress uninstalls a plugin in a request that never included it, as WP-CLI's --skip-plugins does here. - $request = self::request( 'require_once ABSPATH . "wp-admin/includes/plugin.php"; echo wp_json_encode( array( uninstall_plugin( "' . plugin_basename( DWS_TPL_FILE ) . '" ), get_option( "' . DWS_TPL_OPTION_DELETE_DATA . '" ) ) );', '--skip-plugins=' . Plugin::SLUG ); + $request = self::request( 'require_once ABSPATH . "wp-admin/includes/plugin.php"; echo wp_json_encode( array( uninstall_plugin( "' . plugin_basename( DWS_TPL_FILE ) . '" ), get_option( "' . Options::DELETE_DATA . '" ) ) );', '--skip-plugins=' . Plugin::SLUG ); self::assertSame( '[true,false]', $request['last'], $request['output'] ); } finally { @@ -99,7 +100,7 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an global $wpdb; \define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_TPL_FILE ) ); - update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); + update_option( Options::DELETE_DATA, 'yes' ); $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . self::REVISION . "'"; add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_tpl_missing_table' : $query ); add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) ); @@ -110,10 +111,10 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an self::fail( 'The uninstall finished.' ); } catch ( \LogicException $died ) { self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . self::REVISION, $died->getMessage() ); - self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertSame( 'yes', get_option( Options::DELETE_DATA ) ); } finally { remove_all_filters( 'query' ); - delete_option( DWS_TPL_OPTION_DELETE_DATA ); + delete_option( Options::DELETE_DATA ); delete_option( self::REVISION ); dws_tpl_plugin()->migrations?->run(); } diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index e8f01d6..7e1efb7 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Unit; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use PHPUnit\Framework\Attributes\CoversNothing; @@ -21,7 +22,7 @@ public function test_every_kind_is_one_the_uninstall_engine_removes(): void { self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); \preg_match_all( '/(\w+)\?:/', $type[1], $kinds ); - self::assertSame( array(), \array_diff( \array_keys( dws_tpl_get_footprint() ), $kinds[1] ) ); + self::assertSame( array(), \array_diff( \array_keys( Plugin::FOOTPRINT ), $kinds[1] ) ); } public function test_every_entry_is_in_the_plugin_prefix_family(): void { @@ -34,7 +35,7 @@ public function test_every_entry_is_in_the_plugin_prefix_family(): void { ...self::LEGACY_FAMILIES, ); - foreach ( dws_tpl_get_footprint() as $kind => $entries ) { + foreach ( Plugin::FOOTPRINT as $kind => $entries ) { $entries = \in_array( $kind, array( 'meta', 'meta_prefixes' ), true ) ? \array_merge( ...\array_values( $entries ) ) : $entries; foreach ( $entries as $entry ) { $covered = 'as_groups' === $kind ? Plugin::PREFIX === $entry : \array_any( $family, static fn ( string $start ): bool => \str_starts_with( $entry, $start ) ); @@ -46,22 +47,22 @@ public function test_every_entry_is_in_the_plugin_prefix_family(): void { public function test_it_covers_what_the_framework_roots_store(): void { $prefix = Plugin::PREFIX . '_'; - self::assertContains( $prefix, dws_tpl_get_footprint()['option_prefixes'] ); - self::assertContains( '_transient_' . $prefix, dws_tpl_get_footprint()['option_prefixes'] ); - self::assertContains( '_transient_timeout_' . $prefix, dws_tpl_get_footprint()['option_prefixes'] ); - self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_site_prefixes'] ); - self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_global_prefixes'] ); - self::assertContains( $prefix . 'network_notices', dws_tpl_get_footprint()['network_options'] ); - self::assertContains( 'dws_plugin_template/migration/run', dws_tpl_get_footprint()['cron_hooks'] ); - self::assertContains( Plugin::PREFIX, dws_tpl_get_footprint()['as_groups'] ); + self::assertContains( $prefix, Plugin::FOOTPRINT['option_prefixes'] ); + self::assertContains( '_transient_' . $prefix, Plugin::FOOTPRINT['option_prefixes'] ); + self::assertContains( '_transient_timeout_' . $prefix, Plugin::FOOTPRINT['option_prefixes'] ); + self::assertContains( $prefix, Plugin::FOOTPRINT['user_meta_site_prefixes'] ); + self::assertContains( $prefix, Plugin::FOOTPRINT['user_meta_global_prefixes'] ); + self::assertContains( $prefix . 'network_notices', Plugin::FOOTPRINT['network_options'] ); + self::assertContains( 'dws_plugin_template/migration/run', Plugin::FOOTPRINT['cron_hooks'] ); + self::assertContains( Plugin::PREFIX, Plugin::FOOTPRINT['as_groups'] ); } public function test_it_covers_every_option_key(): void { - $keys = \array_filter( \get_defined_constants( true )['user'], static fn ( mixed $value, string $name ): bool => \str_starts_with( $name, 'DWS_TPL_OPTION_' ) && \is_string( $value ), \ARRAY_FILTER_USE_BOTH ); + $keys = \array_filter( new \ReflectionClass( Options::class )->getConstants(), \is_string( ... ) ); self::assertNotSame( array(), $keys ); foreach ( $keys as $key ) { - $covered = \in_array( $key, dws_tpl_get_footprint()['options'] ?? array(), true ) || \array_any( dws_tpl_get_footprint()['option_prefixes'], static fn ( string $prefix ): bool => \str_starts_with( $key, $prefix ) ); + $covered = \in_array( $key, Plugin::FOOTPRINT['options'] ?? array(), true ) || \array_any( Plugin::FOOTPRINT['option_prefixes'], static fn ( string $prefix ): bool => \str_starts_with( $key, $prefix ) ); self::assertTrue( $covered, "The option $key is outside the footprint." ); } } From 2636eed8d4bb906f8f884a3b9e5b9cb36f560ea3 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:55:02 +0100 Subject: [PATCH 08/19] Move the options, migrations and footprint into classes The option keys, defaults, readers, translated text and validator, the migration steps and the footprint were prefixed globals in includes/, so any code could call them and come to depend on them, and the string reader returned the greeting's translated default, mixing what the greeting shows into the data read (X rulings 391, 392 and 408). Options names the stored settings and their defaults as constants and reads each as its type through generic, untranslated statics. Migrations lists its protected static steps as first-class callables, and the composition root holds the footprint as a constant, which deactivation and uninstall.php read. The greeting's component owns its translated default message, which it prints for an empty stored message and the settings field shows as its placeholder; the validator is a protected static of Settings, handed to its field. The three includes go, and the Unit suite loads the classes through the autoloader. Assisted-by: Claude Code:claude-opus-5-5 --- includes/footprint.php | 32 ---- includes/migrations.php | 51 ------- includes/options.php | 172 ---------------------- src/Greeting/AccountDashboardGreeting.php | 29 +++- src/Greeting/GreetingFeature.php | 3 +- src/Migrations.php | 73 +++++++++ src/Options.php | 151 +++++++++++++++++++ src/Plugin.php | 21 ++- src/Settings.php | 44 ++++-- tests/Unit/bootstrap.php | 4 - uninstall.php | 8 +- 11 files changed, 306 insertions(+), 282 deletions(-) delete mode 100644 includes/footprint.php delete mode 100644 includes/migrations.php delete mode 100644 includes/options.php create mode 100644 src/Migrations.php create mode 100644 src/Options.php diff --git a/includes/footprint.php b/includes/footprint.php deleted file mode 100644 index 2122f6c..0000000 --- a/includes/footprint.php +++ /dev/null @@ -1,32 +0,0 @@ -, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} - */ -function dws_tpl_get_footprint(): array { - return array( - 'option_prefixes' => array( Plugin::PREFIX . '_', '_transient_' . Plugin::PREFIX . '_', '_transient_timeout_' . 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( 'dws_plugin_template/migration/run' ), - 'as_groups' => array( Plugin::PREFIX ), - ); -} diff --git a/includes/migrations.php b/includes/migrations.php deleted file mode 100644 index 31b81cf..0000000 --- a/includes/migrations.php +++ /dev/null @@ -1,51 +0,0 @@ - - */ -function dws_tpl_get_migration_steps(): array { - return array( - 'dws_tpl_add_settings', - ); -} - -/** - * Stores the defaults of the settings that need no translation. - * - * A translated default is never stored, because it would keep the language the site had when the step ran. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @throws RuntimeException Thrown when a default could not be stored. - * - * @return void - */ -function dws_tpl_add_settings(): void { - $defaults = array( - DWS_TPL_OPTION_GREETING_ENABLED => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ENABLED ] ), - DWS_TPL_OPTION_GREETING_ORDERS => (string) DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ], - DWS_TPL_OPTION_DELETE_DATA => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ] ), - ); - - foreach ( $defaults as $key => $value ) { - if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { - throw new RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. - } - } -} diff --git a/includes/options.php b/includes/options.php deleted file mode 100644 index 46d95c8..0000000 --- a/includes/options.php +++ /dev/null @@ -1,172 +0,0 @@ - - */ -const DWS_TPL_OPTION_DEFAULTS = array( - DWS_TPL_OPTION_DELETE_DATA => false, - DWS_TPL_OPTION_GREETING_ENABLED => false, - DWS_TPL_OPTION_GREETING_ORDERS => 1, -); - -/** - * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return bool - */ -function dws_tpl_get_bool_option( string $key ): bool { - $default = dws_tpl_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); -} - -/** - * Returns an option as an integer. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return int - */ -function dws_tpl_get_int_option( string $key ): int { - $default = dws_tpl_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); -} - -/** - * Returns an option as a string, reading a missing or empty value of an option with a translated default as that default. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return string - */ -function dws_tpl_get_string_option( string $key ): string { - $text = dws_tpl_get_option_text( $key ); - $default = dws_tpl_get_option_default( $key ) ?? $text; - $stored = get_option( $key, $default ); - if ( '' === $stored ) { - return $text; // The field shows a translated default as its placeholder, so an untouched field stores an empty value. - } - - return is_string( $stored ) ? $stored : ( is_string( $default ) ? $default : '' ); -} - -/** - * Returns an option's translated default, or an empty string for an option without one. - * - * Callers read it from init on, because WordPress flags translations loaded earlier. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return string - */ -function dws_tpl_get_option_text( string $key ): string { - switch ( $key ) { - case DWS_TPL_OPTION_GREETING: - return __( 'Welcome back, and thank you for shopping with us again.', 'dws-plugin-template' ); - default: - return ''; - } -} - -/** - * Returns the number of orders before the greeting shows as the digits WooCommerce stores, or an error when the submission is not a whole number of at least 1. - * - * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param mixed $submitted The submitted value. - * - * @return string|WP_Error - */ -function dws_tpl_validate_greeting_orders( $submitted ) { - $orders = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_FLOAT ) : false; - if ( false === $orders || $orders < 1 || $orders >= PHP_INT_MAX || floor( $orders ) !== $orders ) { - return new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); - } - - return (string) (int) $orders; -} - -/** - * Returns the option's default, or null for an option without one. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return mixed - */ -function dws_tpl_get_option_default( string $key ) { - return DWS_TPL_OPTION_DEFAULTS[ $key ] ?? null; -} diff --git a/src/Greeting/AccountDashboardGreeting.php b/src/Greeting/AccountDashboardGreeting.php index 685f6d0..fccc54e 100644 --- a/src/Greeting/AccountDashboardGreeting.php +++ b/src/Greeting/AccountDashboardGreeting.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Greeting; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; @@ -30,6 +31,24 @@ public function register_hooks(): void { // endregion + // region METHODS + + /** + * Returns the greeting's default message in the visitor's language, which shows wherever the stored message is empty. + * + * The default is never stored, so that saving the settings does not fix one language for every customer. Callers read it from init on, because WordPress flags translations loaded earlier. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @return string + */ + public static function get_default_message(): string { + return __( 'Welcome back, and thank you for shopping with us again.', 'dws-plugin-template' ); + } + + // endregion + // region HOOKS /** @@ -41,12 +60,7 @@ public function register_hooks(): void { * @return void */ public function print_greeting(): void { - $greeting = dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ); - if ( '' === $greeting ) { - return; - } - - $threshold = dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ); + $threshold = Options::get_int( Options::GREETING_ORDERS ); $paid = wc_get_orders( array( 'customer_id' => get_current_user_id(), @@ -59,7 +73,8 @@ public function print_greeting(): void { return; } - wc_print_notice( esc_html( $greeting ), 'notice', array( Plugin::SLUG => 'greeting' ) ); + $greeting = Options::get_string( Options::GREETING ); + wc_print_notice( esc_html( '' === $greeting ? self::get_default_message() : $greeting ), 'notice', array( Plugin::SLUG => 'greeting' ) ); } /** diff --git a/src/Greeting/GreetingFeature.php b/src/Greeting/GreetingFeature.php index 888a060..aba6287 100644 --- a/src/Greeting/GreetingFeature.php +++ b/src/Greeting/GreetingFeature.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Greeting; +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\CompositeComponentInterface; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ConditionalComponentInterface; @@ -24,7 +25,7 @@ final class GreetingFeature implements CompositeComponentInterface, ConditionalC */ #[\Override] public static function should_load(): bool { - return dws_tpl_get_bool_option( DWS_TPL_OPTION_GREETING_ENABLED ); + return Options::get_bool( Options::GREETING_ENABLED ); } /** diff --git a/src/Migrations.php b/src/Migrations.php new file mode 100644 index 0000000..c473e5a --- /dev/null +++ b/src/Migrations.php @@ -0,0 +1,73 @@ + + */ + public static function get_steps(): array { + return array( + self::add_settings( ... ), + ); + } + + // endregion + + // region HELPERS + + /** + * Stores the defaults of the settings that need no translation. + * + * A translated default is never stored, because it would keep the language the site had when the step ran. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @throws \RuntimeException Thrown when a default could not be stored. + * + * @return void + */ + protected static function add_settings(): void { + $defaults = array( + Options::GREETING_ENABLED => wc_bool_to_string( Options::DEFAULTS[ Options::GREETING_ENABLED ] ), + Options::GREETING_ORDERS => (string) Options::DEFAULTS[ Options::GREETING_ORDERS ], + Options::DELETE_DATA => wc_bool_to_string( Options::DEFAULTS[ Options::DELETE_DATA ] ), + ); + + foreach ( $defaults as $key => $value ) { + if ( false === get_option( $key ) && ! add_option( $key, $value, '', true ) ) { + throw new \RuntimeException( "The option $key could not be stored." ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } + } + } + + // endregion +} diff --git a/src/Options.php b/src/Options.php new file mode 100644 index 0000000..afc95cf --- /dev/null +++ b/src/Options.php @@ -0,0 +1,151 @@ + + */ + public const array DEFAULTS = array( + self::DELETE_DATA => false, + self::GREETING_ENABLED => false, + self::GREETING_ORDERS => 1, + ); + + // endregion + + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + */ + protected function __construct() {} + + // endregion + + // region METHODS + + /** + * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return bool + */ + public static function get_bool( string $key ): bool { + $default = self::get_default( $key ); + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); + } + + /** + * Returns an option as an integer. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return int + */ + public static function get_int( string $key ): int { + $default = self::get_default( $key ); + return \filter_var( get_option( $key, $default ), \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? ( \is_int( $default ) ? $default : 0 ); + } + + /** + * Returns an option as a string, or its default, or an empty string, where no string is stored. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return string + */ + public static function get_string( string $key ): string { + $default = self::get_default( $key ); + $stored = get_option( $key, $default ); + return \is_string( $stored ) ? $stored : ( \is_string( $default ) ? $default : '' ); + } + + // endregion + + // region HELPERS + + /** + * Returns the option's default, or null for an option without one. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return mixed + */ + protected static function get_default( string $key ): mixed { + return self::DEFAULTS[ $key ] ?? null; + } + + // endregion +} diff --git a/src/Plugin.php b/src/Plugin.php index 58a7b22..906623a 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -61,6 +61,23 @@ final class Plugin { Greeting\GreetingFeature::class, ); + /** + * The plugin's stored data by kind, which uninstall and deactivation read. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} + */ + public const array FOOTPRINT = array( + 'option_prefixes' => array( self::PREFIX . '_', '_transient_' . self::PREFIX . '_', '_transient_timeout_' . self::PREFIX . '_' ), + 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), + 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), + 'network_options' => array( self::PREFIX . '_network_notices' ), + 'cron_hooks' => array( 'dws_plugin_template/migration/run' ), + 'as_groups' => array( self::PREFIX ), + ); + /** * The boot boundary, whose status tells whether the plugin booted. * @@ -164,7 +181,7 @@ public function boot(): void { */ public function deactivate(): void { foreach ( array( 'cron_hooks', 'as_hooks' ) as $kind ) { - foreach ( dws_tpl_get_footprint()[ $kind ] ?? array() as $hook ) { + foreach ( self::FOOTPRINT[ $kind ] ?? array() as $hook ) { $unscheduled = $this->scheduler->unschedule_hook( $hook ); if ( true !== $unscheduled ) { $this->logger->warning( @@ -198,7 +215,7 @@ protected function compose(): bool { return false; } - $this->migrations = new MigrationRunner( $this->identity, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); diff --git a/src/Settings.php b/src/Settings.php index ad36ea3..39526ff 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate; +use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SectionFieldsDefinitionInterface; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; @@ -45,30 +46,30 @@ public function get_section_fields( string $tab_id, string $section_id ): array ), array( 'type' => 'checkbox', - 'id' => DWS_TPL_OPTION_GREETING_ENABLED, + 'id' => Options::GREETING_ENABLED, 'title' => __( 'Show the greeting', 'dws-plugin-template' ), 'desc' => __( 'Greet returning customers', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ENABLED ] ), + 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::GREETING_ENABLED ] ), ), array( 'type' => 'text', - 'id' => DWS_TPL_OPTION_GREETING, + 'id' => Options::GREETING, 'title' => __( 'Message', 'dws-plugin-template' ), 'desc' => __( 'Leave empty to show the default greeting in each customer\'s language.', 'dws-plugin-template' ), 'default' => '', - 'placeholder' => dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), // A default would be saved in the admin's language with the first save of the tab. + 'placeholder' => AccountDashboardGreeting::get_default_message(), // A default would be saved in the admin's language with the first save of the tab. ), array( 'type' => 'number', - 'id' => DWS_TPL_OPTION_GREETING_ORDERS, + 'id' => Options::GREETING_ORDERS, 'title' => __( 'Orders before the greeting', 'dws-plugin-template' ), 'desc' => __( 'Customers see the greeting once this many of their orders are paid.', 'dws-plugin-template' ), - 'default' => (string) DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ], + 'default' => (string) Options::DEFAULTS[ Options::GREETING_ORDERS ], 'custom_attributes' => array( 'min' => '1', 'step' => '1', ), - 'validate' => 'dws_tpl_validate_greeting_orders', + 'validate' => self::validate_greeting_orders( ... ), ), array( 'type' => 'sectionend', @@ -81,10 +82,10 @@ public function get_section_fields( string $tab_id, string $section_id ): array ), array( 'type' => 'checkbox', - 'id' => DWS_TPL_OPTION_DELETE_DATA, + 'id' => Options::DELETE_DATA, 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ] ), + 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::DELETE_DATA ] ), ), array( 'type' => 'sectionend', @@ -120,4 +121,29 @@ public function add_settings_tab( array $pages ): array { } // endregion + + // region HELPERS + + /** + * Returns the number of orders before the greeting shows as the digits WooCommerce stores, or an error when the submission is not a whole number of at least 1. + * + * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param mixed $submitted The submitted value. + * + * @return string|\WP_Error + */ + protected static function validate_greeting_orders( mixed $submitted ): string|\WP_Error { + $orders = \is_string( $submitted ) ? \filter_var( $submitted, \FILTER_VALIDATE_FLOAT ) : false; + if ( false === $orders || $orders < 1 || $orders >= \PHP_INT_MAX || \floor( $orders ) !== $orders ) { + return new \WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); + } + + return (string) (int) $orders; + } + + // endregion } diff --git a/tests/Unit/bootstrap.php b/tests/Unit/bootstrap.php index af0ab55..3375ba1 100644 --- a/tests/Unit/bootstrap.php +++ b/tests/Unit/bootstrap.php @@ -2,7 +2,3 @@ // The plugin's files exit when WordPress has not defined ABSPATH. define( 'ABSPATH', __DIR__ . '/' ); - -// WordPress's include loader is absent here, so the suite requires the declarations it reads. -require_once dirname( __DIR__, 2 ) . '/includes/options.php'; -require_once dirname( __DIR__, 2 ) . '/includes/footprint.php'; diff --git a/uninstall.php b/uninstall.php index e3dd2cb..3e4cd49 100644 --- a/uninstall.php +++ b/uninstall.php @@ -8,6 +8,7 @@ * @package DeepWebSolutions\PluginTemplate */ +use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Bootstrap\Requirements; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; @@ -29,9 +30,8 @@ } require_once __DIR__ . '/vendor/autoload.php'; - require_once __DIR__ . '/includes/footprint.php'; - require_once __DIR__ . '/includes/options.php'; - $dws_tpl_footprint = dws_tpl_get_footprint(); + $dws_tpl_footprint = Plugin::FOOTPRINT; + $dws_tpl_consent = Options::DELETE_DATA; $dws_tpl_meta_types = MetaTypes::DEFINITIONS; if ( ! class_exists( Uninstaller::class ) ) { throw new RuntimeException( 'its uninstall engine is missing.' ); @@ -53,7 +53,7 @@ $dws_tpl_identity, $dws_tpl_footprint, array( - 'consent' => array( 'option' => DWS_TPL_OPTION_DELETE_DATA ), + 'consent' => array( 'option' => $dws_tpl_consent ), 'meta_types' => $dws_tpl_meta_types, ) ); From f060c579905be2f8470a1472eb74c9382a524a67 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:56:14 +0100 Subject: [PATCH 09/19] Register built assets through core's Assets The template carried its own registrars for a wp-scripts build, global functions every plugin copied, while core's Assets registers a plugin's built scripts and styles from the same asset files, falls back to the file's time where none exists and swaps in a right-to-left stylesheet only where its file exists (X rulings 395, 398, 399 and 406). The composition root defines Assets over the plugin's identity, and Blocks and the greeting's component take it by constructor. The include and its test go, with their fixture, since core's suite covers Assets. Assisted-by: Claude Code:claude-opus-5-5 --- includes/assets.php | 77 ------------------- src/Blocks.php | 19 ++++- src/Greeting/AccountDashboardGreeting.php | 19 ++++- src/Plugin.php | 2 + tests/Fixtures/built.asset.php | 12 --- tests/Integration/AssetsTest.php | 45 ----------- tests/Integration/BlocksTest.php | 10 ++- .../Greeting/AccountDashboardGreetingTest.php | 20 +++-- 8 files changed, 59 insertions(+), 145 deletions(-) delete mode 100644 includes/assets.php delete mode 100644 tests/Fixtures/built.asset.php delete mode 100644 tests/Integration/AssetsTest.php diff --git a/includes/assets.php b/includes/assets.php deleted file mode 100644 index 705c965..0000000 --- a/includes/assets.php +++ /dev/null @@ -1,77 +0,0 @@ -, version: string, type?: string, handle?: string} - */ -function dws_tpl_get_asset( string $path ): array { - /** - * The payload wp-scripts generates. - * - * @var array{dependencies: list, version: string, type?: string, handle?: string} $asset - */ - $asset = require plugin_dir_path( DWS_TPL_FILE ) . dirname( $path ) . '/' . pathinfo( $path, PATHINFO_FILENAME ) . '.asset.php'; - return $asset; -} - -/** - * Registers a built script with its generated dependencies and version, and loads its translations from languages/. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @phpstan-param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args - * - * @param non-empty-string $handle The script's handle. - * @param non-empty-string $path The built script's path, relative to the plugin's directory. - * @param array $args Optional. How the script loads. Default none, which loads it in the head. - * - * @return bool - */ -function dws_tpl_register_script( string $handle, string $path, array $args = array() ): bool { - $asset = dws_tpl_get_asset( $path ); - $registered = wp_register_script( $handle, plugin_dir_url( DWS_TPL_FILE ) . $path, array_values( array_filter( $asset['dependencies'], 'is_string' ) ), $asset['version'], $args ); - if ( $registered ) { - wp_set_script_translations( $handle, Plugin::SLUG, plugin_dir_path( DWS_TPL_FILE ) . 'languages' ); - } - - return $registered; -} - -/** - * Registers a built stylesheet with its generated version, and its right-to-left variant for right-to-left languages. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param non-empty-string $handle The stylesheet's handle. - * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. - * - * @return bool - */ -function dws_tpl_register_style( string $handle, string $path ): bool { - $registered = wp_register_style( $handle, plugin_dir_url( DWS_TPL_FILE ) . $path, array(), dws_tpl_get_asset( $path )['version'] ); - if ( $registered ) { - wp_style_add_data( $handle, 'rtl', 'replace' ); - } - - return $registered; -} diff --git a/src/Blocks.php b/src/Blocks.php index 130a54b..507c18b 100644 --- a/src/Blocks.php +++ b/src/Blocks.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Asset\Assets; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; \defined( 'ABSPATH' ) || exit; @@ -13,6 +14,22 @@ * @version 1.0.0 */ final class Blocks implements ComponentInterface { + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param Assets $assets The registrar of the plugin's built scripts and styles. + */ + public function __construct( + protected Assets $assets + ) {} + + // endregion + // region INHERITED METHODS /** @@ -52,7 +69,7 @@ public function register_blocks(): void { * @return void */ public function enqueue_editor_script(): void { - if ( dws_tpl_register_script( Plugin::SLUG . '-editor', 'assets/build/editor.js' ) ) { + if ( $this->assets->register_script( Plugin::SLUG . '-editor', 'assets/build/editor.js' ) ) { wp_enqueue_script( Plugin::SLUG . '-editor' ); } } diff --git a/src/Greeting/AccountDashboardGreeting.php b/src/Greeting/AccountDashboardGreeting.php index fccc54e..95dfa87 100644 --- a/src/Greeting/AccountDashboardGreeting.php +++ b/src/Greeting/AccountDashboardGreeting.php @@ -4,6 +4,7 @@ use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Asset\Assets; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\ComponentInterface; \defined( 'ABSPATH' ) || exit; @@ -15,6 +16,22 @@ * @version 1.0.0 */ final class AccountDashboardGreeting implements ComponentInterface { + // region MAGIC METHODS + + /** + * Constructor. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param Assets $assets The registrar of the plugin's built scripts and styles. + */ + public function __construct( + protected Assets $assets + ) {} + + // endregion + // region INHERITED METHODS /** @@ -90,7 +107,7 @@ public function enqueue_style(): void { return; } - if ( dws_tpl_register_style( Plugin::SLUG . '-greeting', 'assets/build/greeting.css' ) ) { + if ( $this->assets->register_style( Plugin::SLUG . '-greeting', 'assets/build/greeting.css' ) ) { wp_enqueue_style( Plugin::SLUG . '-greeting' ); } } diff --git a/src/Plugin.php b/src/Plugin.php index 906623a..43c1056 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Asset\Assets; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginBoot; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; @@ -242,6 +243,7 @@ protected function get_definitions( PluginIdentity $identity, PsrLoggerInterface Scheduler::class => static fn (): Scheduler => $scheduler, SchedulerInterface::class => static fn (): Scheduler => $scheduler, AdminNotices::class => static fn (): AdminNotices => new AdminNotices( $identity, $logger ), + Assets::class => static fn (): Assets => new Assets( $identity ), ); } diff --git a/tests/Fixtures/built.asset.php b/tests/Fixtures/built.asset.php deleted file mode 100644 index 77d14fe..0000000 --- a/tests/Fixtures/built.asset.php +++ /dev/null @@ -1,12 +0,0 @@ - array( - 'wp-i18n', - array( - 'id' => '@wordpress/interactivity', - 'import' => 'dynamic', - ), - ), - 'version' => 'fixture', -); diff --git a/tests/Integration/AssetsTest.php b/tests/Integration/AssetsTest.php deleted file mode 100644 index 2282f29..0000000 --- a/tests/Integration/AssetsTest.php +++ /dev/null @@ -1,45 +0,0 @@ - 'defer' ) ) ); - - $script = wp_scripts()->registered[ self::HANDLE ]; - self::assertSame( plugins_url( 'tests/Fixtures/built.js', DWS_TPL_FILE ), $script->src ); - self::assertSame( array( 'wp-i18n' ), $script->deps ); - self::assertSame( 'fixture', $script->ver ); - self::assertSame( 'defer', $script->extra['strategy'] ?? null ); - self::assertSame( Plugin::SLUG, $script->textdomain ); - self::assertSame( plugin_dir_path( DWS_TPL_FILE ) . 'languages', $script->translations_path ); - } - - public function test_a_stylesheet_registers_with_its_generated_version_and_its_right_to_left_variant(): void { - self::assertTrue( dws_tpl_register_style( self::HANDLE, 'tests/Fixtures/built.css' ) ); - - $style = wp_styles()->registered[ self::HANDLE ]; - self::assertSame( plugins_url( 'tests/Fixtures/built.css', DWS_TPL_FILE ), $style->src ); - self::assertSame( 'fixture', $style->ver ); - self::assertSame( 'all', $style->args ); - self::assertSame( 'replace', $style->extra['rtl'] ?? null ); - } -} diff --git a/tests/Integration/BlocksTest.php b/tests/Integration/BlocksTest.php index baaf851..9fc0a8e 100644 --- a/tests/Integration/BlocksTest.php +++ b/tests/Integration/BlocksTest.php @@ -4,6 +4,8 @@ use DeepWebSolutions\PluginTemplate\Blocks; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Asset\Assets; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; @@ -51,7 +53,7 @@ public function test_the_example_block_renders_its_text_in_the_visitors_language } public function test_the_block_editor_gets_the_editor_script_with_its_generated_dependencies(): void { - $blocks = new Blocks(); + $blocks = new Blocks( self::make_assets() ); $blocks->register_hooks(); self::assertSame( 10, has_action( 'enqueue_block_editor_assets', array( $blocks, 'enqueue_editor_script' ) ) ); remove_action( 'init', array( $blocks, 'register_blocks' ) ); @@ -68,8 +70,12 @@ public function test_the_block_editor_gets_the_editor_script_with_its_generated_ public function test_the_block_editor_gets_only_an_editor_script_the_plugin_registered(): void { wp_register_script( Plugin::SLUG . '-editor', 'https://example.org/other.js', array(), '1', true ); - new Blocks()->enqueue_editor_script(); + new Blocks( self::make_assets() )->enqueue_editor_script(); self::assertFalse( wp_script_is( Plugin::SLUG . '-editor', 'enqueued' ) ); } + + protected static function make_assets(): Assets { + return new Assets( new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX ) ); + } } diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index 00d7892..bb26a98 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -5,6 +5,8 @@ use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Asset\Assets; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -68,7 +70,7 @@ protected function clean_up(): void { } public function test_the_greeting_hooks_the_account_dashboard_and_its_style(): void { - $detail = new AccountDashboardGreeting(); + $detail = new AccountDashboardGreeting( self::make_assets() ); $detail->register_hooks(); self::assertSame( 10, has_action( 'woocommerce_account_dashboard', array( $detail, 'print_greeting' ) ) ); @@ -78,12 +80,12 @@ public function test_the_greeting_hooks_the_account_dashboard_and_its_style(): v } public function test_the_greeting_style_loads_on_the_account_dashboard_only(): void { - new AccountDashboardGreeting()->enqueue_style(); + new AccountDashboardGreeting( self::make_assets() )->enqueue_style(); self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) ); add_filter( 'woocommerce_is_account_page', '__return_true' ); try { - new AccountDashboardGreeting()->enqueue_style(); + new AccountDashboardGreeting( self::make_assets() )->enqueue_style(); } finally { remove_filter( 'woocommerce_is_account_page', '__return_true' ); } @@ -98,7 +100,7 @@ public function test_the_greeting_style_skips_the_account_page_endpoints(): void add_filter( 'woocommerce_is_account_page', '__return_true' ); $wp->query_vars['orders'] = ''; try { - new AccountDashboardGreeting()->enqueue_style(); + new AccountDashboardGreeting( self::make_assets() )->enqueue_style(); } finally { unset( $wp->query_vars['orders'] ); remove_filter( 'woocommerce_is_account_page', '__return_true' ); @@ -111,7 +113,7 @@ public function test_the_greeting_style_skips_logged_out_visitors(): void { wp_set_current_user( 0 ); add_filter( 'woocommerce_is_account_page', '__return_true' ); try { - new AccountDashboardGreeting()->enqueue_style(); + new AccountDashboardGreeting( self::make_assets() )->enqueue_style(); } finally { remove_filter( 'woocommerce_is_account_page', '__return_true' ); } @@ -123,7 +125,7 @@ public function test_the_greeting_style_enqueues_only_a_stylesheet_it_registered wp_register_style( 'dws-plugin-template-greeting', 'https://example.org/other.css', array(), '1' ); add_filter( 'woocommerce_is_account_page', '__return_true' ); try { - new AccountDashboardGreeting()->enqueue_style(); + new AccountDashboardGreeting( self::make_assets() )->enqueue_style(); } finally { remove_filter( 'woocommerce_is_account_page', '__return_true' ); } @@ -201,7 +203,11 @@ protected function place_orders( int $count, string $status = 'completed' ): voi protected function print(): string { \ob_start(); - new AccountDashboardGreeting()->print_greeting(); + new AccountDashboardGreeting( self::make_assets() )->print_greeting(); return (string) \ob_get_clean(); } + + protected static function make_assets(): Assets { + return new Assets( new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX ) ); + } } From 4c274d41395ba43039019e5a44ec71eb25c3101c Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:56:41 +0100 Subject: [PATCH 10/19] Expect only contract in the global namespace Any code can call a global function or read a global constant, so a plugin built from the template comes to support whatever it declares there, while functions.php still declared the accessor without @api (X rulings 384 and 397). GlobalsTest reads functions.php and every file in includes/ and expects each top-level function and constant they declare to carry @api. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Unit/GlobalsTest.php | 48 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Unit/GlobalsTest.php diff --git a/tests/Unit/GlobalsTest.php b/tests/Unit/GlobalsTest.php new file mode 100644 index 0000000..d4363fb --- /dev/null +++ b/tests/Unit/GlobalsTest.php @@ -0,0 +1,48 @@ + $token->is( \T_DOC_COMMENT ) || ! $token->isIgnorable() ) ); + $docs = ''; + $import = false; + foreach ( $tokens as $index => $token ) { + if ( $token->is( \T_DOC_COMMENT ) ) { + $docs = $token->text; + continue; + } + + // An import names symbols declared elsewhere, so nothing in it counts until its semicolon. + if ( $token->is( \T_USE ) && true !== ( $tokens[ $index + 1 ] ?? null )?->is( '(' ) ) { + $import = true; + } + + // No class lives in these files, so a named function or a define() call declares a global even inside a block, such as a function_exists() check, and carries its own docblock there. + $is_define = $token->is( array( \T_STRING, \T_NAME_FULLY_QUALIFIED ) ) && 'define' === \strtolower( \ltrim( $token->text, '\\' ) ) && true === ( $tokens[ $index + 1 ] ?? null )?->is( '(' ) && true !== ( $tokens[ $index - 1 ] ?? null )?->is( array( \T_OBJECT_OPERATOR, \T_NULLSAFE_OBJECT_OPERATOR, \T_DOUBLE_COLON ) ); + $name = $tokens[ $index + ( $is_define ? 2 : 1 ) ] ?? null; + if ( ! $import && ( $is_define || $token->is( array( \T_FUNCTION, \T_CONST ) ) ) && true === $name?->is( array( \T_STRING, \T_CONSTANT_ENCAPSED_STRING ) ) && 1 !== \preg_match( '/^[ \t]*\/?\*+[ \t]*@api\s/m', $docs ) ) { + $untagged[] = \basename( $file ) . ': ' . \trim( $name->text, '\'"' ); + } + + // A docblock lasts until its statement ends or opens a block, so a guard such as defined() || define() or an attribute keeps it. + if ( $token->is( array( ';', '{', '}' ) ) ) { + $docs = ''; + $import = $import && ! $token->is( ';' ); + } + } + } + + self::assertSame( array(), $untagged ); + } +} From c8fdead596ea1a39f9e10e506f43ec8de17b5130 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:56:48 +0100 Subject: [PATCH 11/19] Tag the accessor as the plugin's API The accessor is the one global through which other code reaches the plugin, yet carried no @api, so nothing told an integration that it may rely on the function while the composition root it returns stays internal (X ruling 397). dws_tpl_plugin() carries @api, and its docblock states that only the function is contract and the root's members stay internal unless tagged. Assisted-by: Claude Code:claude-opus-5-5 --- functions.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/functions.php b/functions.php index b66dce1..9f4b259 100644 --- a/functions.php +++ b/functions.php @@ -49,6 +49,10 @@ static function (): void { /** * Returns the plugin's composition root, built on first use. * + * Only the function is contract: the root's members stay internal unless tagged @api. + * + * @api + * * @since 1.0.0 * @version 1.0.0 * From 71d5dc4ce2d37e96fde54f9c1693e7884d27dcbc Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:59:33 +0100 Subject: [PATCH 12/19] Click the Outlined style as a radio or a button The nightly on WordPress trunk timed out clicking the example block's Outlined style, since WordPress 7.2 renders block styles as radios in a Styles radiogroup, while 7.1, the floor CI also tests, renders them as buttons (X ruling 371). The editor spec clicks the Outlined radio, or the Outlined button where none exists, within the Editor settings region. Assisted-by: Claude Code:claude-opus-5-5 --- tests/EndToEnd/editor.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/EndToEnd/editor.spec.ts b/tests/EndToEnd/editor.spec.ts index bb831c0..4966ba4 100644 --- a/tests/EndToEnd/editor.spec.ts +++ b/tests/EndToEnd/editor.spec.ts @@ -39,9 +39,11 @@ test.describe( 'Block editor', () => { await expect( editor.canvas.getByText( TEXT ) ).toBeVisible(); await editor.openDocumentSettingsSidebar(); - await page - .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: 'Outlined' } ) + const sidebar = page.getByRole( 'region', { name: 'Editor settings' } ); + // WordPress 7.2 renders block styles as radios, which earlier releases render as buttons. + await sidebar + .getByRole( 'radio', { name: 'Outlined' } ) + .or( sidebar.getByRole( 'button', { name: 'Outlined' } ) ) .click(); await expect .poll( editor.getBlocks ) From 468fa07693abd16a9074937c826be307d31dd7b2 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 20:06:10 +0100 Subject: [PATCH 13/19] Describe the class anatomy in the docs and recipes The agent guide, the architecture, the scaffold notes, the test guide and every recipe still named the global helpers and constants that moved into Safeguards, Options, Migrations and Plugin::FOOTPRINT, the dws_tpl hook names that moved under the slug, and the framework constructors that now take the plugin's identity, so a plugin following them would rebuild the anatomy the template left. Describe the entry's one catch, the static-closure hooks, the class homes of options, migrations and the footprint, core's Assets and the identity, and port each recipe's code to them. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 30 ++- README.scaffold.md | 4 +- docs/ARCHITECTURE.md | 24 +-- docs/recipes.md | 485 ++++++++++++++++++++++++------------------- tests/README.md | 4 +- 5 files changed, 297 insertions(+), 250 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f0d043d..8c6a44e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,20 +5,18 @@ A WordPress plugin built on the five wp-framework packages (`ahegyes/wp-framewor ## Layout ``` -dws-plugin-template.php the entry: safeguards, bootstrap load.php, the updater, the floor check, the build gate, the autoloader -functions.php dws_tpl_plugin(), the boot on plugins_loaded 15, deactivation, the HPOS declaration (false), the includes/ loader -uninstall.php the framework's uninstall engine over the footprint, with DWS_TPL_OPTION_DELETE_DATA's consent -src/ the composition root and its components only -├── Plugin.php SLUG, PREFIX (dws_tpl), COMPONENTS, the logger, the scheduler, the migration runner and the kernel -├── Settings.php the WooCommerce settings tab dws_tpl: the greeting's fields and the uninstall consent +dws-plugin-template.php the entry: the safeguards, then, in one try, bootstrap load.php, the updater, the floor check, the build gate, the autoloader and functions.php, whose catch shows the build notice +Safeguards.php the safeguards, as static closures, which the entry requires before the floor check +functions.php the @api dws_tpl_plugin(), the includes/ loader, then the boot on plugins_loaded 15, the HPOS declaration (false) and deactivation, as static closures +uninstall.php the framework's uninstall engine over Plugin::FOOTPRINT, with Options::DELETE_DATA's consent +src/ the plugin's classes, each named for its concept +├── Plugin.php SLUG, PREFIX (dws_tpl), COMPONENTS, FOOTPRINT, the identity, the logger, the scheduler, the migration runner and the kernel +├── Options.php the option keys, their defaults and the typed readers +├── Migrations.php get_steps() over the protected steps, append-only +├── Settings.php the WooCommerce settings tab dws_tpl: the greeting's fields, their validator and the uninstall consent ├── Blocks.php the blocks from blocks/build/blocks-manifest.php and the block-editor script -└── Greeting/ the greeting: GreetingFeature, gated on DWS_TPL_OPTION_GREETING_ENABLED, and its AccountDashboardGreeting child -includes/ hook-free code as dws_tpl_ functions and DWS_TPL_ constants, loaded in name order, _-prefixed files skipped -├── safeguards.php restrictions that hold whenever the plugin cannot boot; loaded by the entry before the floor check -├── options.php the option keys, their defaults, the typed readers, the translated text and the validator -├── footprint.php dws_tpl_get_footprint(), every kind of data the plugin stores -├── migrations.php dws_tpl_get_migration_steps() and one function per step, append-only -├── assets.php the *.asset.php reader and the script and style registrars +└── Greeting/ the greeting: GreetingFeature, gated on Options::GREETING_ENABLED, and its AccountDashboardGreeting child, which owns the default message +includes/ more @api functions and constants, grouped by topic, loaded in name order, _-prefixed files skipped └── metadata.php the slug, name and version getters, the plugin's surface for a peer plugin blocks/src/, assets/src/ TypeScript, SCSS and each block's render.php, built into blocks/build/ and assets/build/ (untracked) models/, templates/ empty extension points @@ -52,13 +50,13 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru ## Conventions -1. **Placement.** `src/` holds only the composition root and its components. Code that registers no hooks, such as keys, readers, the footprint, migration steps and asset registrars, is `dws_tpl_` functions and `DWS_TPL_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. +1. **Placement.** The global namespace holds only the plugin's contract: `functions.php` and `includes/` declare its `@api` functions and constants, and `functions.php` registers its hooks as static closures. Every other symbol is a member of a final class in `src/` named for its concept, `protected` wherever only its class uses it: the composition root, which holds the footprint, a component, `Options`, `Migrations` or another class that registers no hooks. No namespace or folder is named "Internal". Container definitions are composition and stay in `Plugin::get_definitions()`; a component takes the plugin's identity, or a service such as `Assets`, by constructor. 2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. -6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. -7. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file, and WooCommerce where the plugin requires it, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one function, which the file runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the whole file, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. +6. **Options and data.** Options are read only through `Options`' typed readers, which translate nothing, safeguards excepted; a translated default belongs to the component that shows it, as the greeting's message does. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta, but for scheduled jobs, whose hooks take the hook root as every hook does and whose Action Scheduler group is the bare `dws_tpl`. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else, such as a scheduled hook, its own entry. +7. **Safeguards** live in `Safeguards.php`, a final class whose `register()` hooks each safeguard as a static closure, which no code can remove by name, so a safeguard relaxes only through its own filter, through the protected `is_relaxed()` (`docs/recipes.md`); only what other classes read is public. The entry requires the class by path before the floor check, so it parses on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one closure, which `register()` runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the class and the entry, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. 9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. diff --git a/README.scaffold.md b/README.scaffold.md index 24d0536..b1f5e91 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -55,7 +55,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas | Part | Where it lives | Removing it | | --- | --- | --- | -| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php`, and the `DWS_TPL_OPTION_GREETING_ENABLED`, `DWS_TPL_OPTION_GREETING` and `DWS_TPL_OPTION_GREETING_ORDERS` keys in `includes/options.php` | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, text and validator, their lines in `dws_tpl_add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. | +| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php` with its validator, and the `GREETING_ENABLED`, `GREETING` and `GREETING_ORDERS` keys in `src/Options.php` | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php` with `validate_greeting_orders()`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, their lines in `Migrations::add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. | | The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` and rendered by its `render.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, `BlocksTest`'s block rows, and the editor script's command, which inserts the block, with `tests/EndToEnd/editor.spec.ts`. | | The editor script | `assets/src/editor.ts`, a command in the editor's command palette that inserts the example block, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `@wordpress/commands` and `@wordpress/data` from `package.json`'s `devDependencies`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, `BlocksTest`'s editor row and `tests/EndToEnd/editor.spec.ts`. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. | | `models/` and `templates/` | Empty extension points, for classes on Composer's classmap and for template partials | Delete the folders the plugin never fills, and `models/` from `composer.json`'s `autoload.classmap` and `phpstan.dist.neon`'s `paths`, since Composer and PHPStan both fail on a missing folder. | @@ -70,5 +70,5 @@ Whichever parts go, four settings follow what is left, since a plugin removes th Still to decide by hand: -- WooCommerce's order tables: `dws_tpl_declare_compatibility()` in `functions.php` declares the plugin incompatible until its order code is verified on both storages. +- WooCommerce's order tables: the `before_woocommerce_init` closure in `functions.php` declares the plugin incompatible until its order code is verified on both storages. - `SECURITY.md`'s scope, whose first out-of-scope line names the plugins generated from the template. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff712c0..e5a1a3c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,18 +4,18 @@ This document describes how the plugin loads, boots, stores its data and leaves ## Loading -`dws-plugin-template.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error; `phpstan.safeguards.neon` analyzes it with the safeguards at PHP 7.4 and the oldest WordPress the bootstrap package runs on: +`dws-plugin-template.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error; `phpstan.safeguards.neon` analyzes it with `Safeguards.php` at PHP 7.4 and the oldest WordPress the bootstrap package runs on: 1. It defines `DWS_TPL_FILE` and registers `languages/`, which WordPress skips for a network-activated plugin. -2. It loads `includes/safeguards.php`, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. -3. It stops with a notice when the prefixed framework is missing. Otherwise it requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, then checks the `Requires PHP` and `Requires at least` headers. The updater comes first, so a release that lowers the floors still reaches a site below them. On a site below a floor, WordPress's own notice shows and nothing past the safeguards loads. -4. It stops with the same notice when the built blocks or assets are missing. It then requires the autoloader and `functions.php`; a missing file throws, which local and development environments rethrow and every other environment logs, showing the same notice. +2. It requires `Safeguards.php` and registers its safeguards, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. +3. Inside one `try`, it requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, then checks the `Requires PHP` and `Requires at least` headers. The updater comes first, so a release that lowers the floors still reaches a site below them. On a site below a floor, WordPress's own notice shows and nothing past the safeguards loads. +4. It requires the autoloader and `functions.php` once the built blocks and assets are present. A missing bootstrap package, build tree or required file throws, which local and development environments rethrow, while every other environment shows users who can activate plugins a notice that the build is incomplete. -`functions.php` defines the `dws_tpl_plugin()` accessor, boots the plugin on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded, registers the deactivation callback, declares the plugin's WooCommerce feature compatibility, and requires every `includes/*.php` in name order except those whose names start with `_`. +`functions.php` defines the `dws_tpl_plugin()` accessor, requires every `includes/*.php` in name order except those whose names start with `_`, then, from static closures, boots the plugin on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded, declares the plugin's WooCommerce feature compatibility and registers the deactivation callback. ## Booting -`src/Plugin.php` is the composition root. Its constructor builds the logger, the scheduler and the framework's `PluginBoot`, which runs the composition and reports its outcome: +`src/Plugin.php` is the composition root. Its constructor builds the plugin's identity from its entry file, slug and prefix, then the logger, the scheduler and the framework's `PluginBoot`, which each take the identity, and `PluginBoot` runs the composition and reports its outcome: - The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked. - `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_plugin_template/boot/blocked` fires, and users who can activate plugins see why the plugin stays off. @@ -27,16 +27,16 @@ The request that activates the plugin does not boot it, because WordPress includ ## Where code lives -- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. A component takes ungated services in its constructor, such as the logger, the scheduler or `AdminNotices`, which `Plugin::get_definitions()` provides. -- `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: the option keys, defaults and typed readers (`options.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`), the asset registrars (`assets.php`) and the slug, name and version getters a peer plugin reads (`metadata.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` runs before the floor check, and `phpstan.safeguards.neon` analyzes it at PHP 7.4 and the oldest WordPress the bootstrap package runs on. -- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`, and each block's `render.php`, which it copies beside the block's build and WordPress runs on each request, so the block's text follows the visitor's language; `includes/assets.php` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`. +- `src/` holds the plugin's classes, each final and named for its concept: the composition root; the components, which implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`; and classes that register no hooks, `Options` and `Migrations`. A component takes the plugin's identity and ungated services in its constructor, such as the logger, the scheduler, `AdminNotices` or `Assets`, which `Plugin::get_definitions()` provides; a component that builds a settings page, product data tab or form passes the identity to it. +- `includes/` extends `functions.php` with more of the plugin's `@api` functions and constants, grouped by topic: the slug, name and version getters a peer plugin reads (`metadata.php`). The global namespace holds nothing else, so the plugin supports only what it tags as API. +- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`, and each block's `render.php`, which it copies beside the block's build and WordPress runs on each request, so the block's text follows the visitor's language; the framework's `Assets` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`. - `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials. ## Data -- **Options** are prefixed constants in `includes/options.php`, read only through its typed readers, which guard each stored value and fall back to the key's default. Translated defaults come from `dws_tpl_get_option_text()` after `init`; a field shows one as its placeholder and stores an empty value, which its reader reads as the default, so no save stores the admin's language. -- **Migrations** are the append-only steps `dws_tpl_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_tpl_revision`. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. -- **The footprint**, `dws_tpl_get_footprint()`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Options, transients and user meta under the `dws_tpl_` prefix are covered; meta on posts, terms, comments or orders needs a `meta_prefixes` entry per object type, and data under another name its own entry, which no check enforces beyond the option constants `FootprintTest` covers. +- **Options** are `Options`' constants, read only through its typed readers, which guard each stored value, fall back to the key's default and translate nothing. A translated default belongs to the component that shows it, after `init`: the greeting's field shows its default message as the placeholder and stores an empty value, which the greeting reads as that message in each visitor's language, so no save stores the admin's language. +- **Migrations** are the append-only steps `Migrations::get_steps()` returns, each a protected static of the class. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_tpl_revision`. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. +- **The footprint**, `Plugin::FOOTPRINT`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Options, transients and user meta under the `dws_tpl_` prefix are covered; meta on posts, terms, comments or orders needs a `meta_prefixes` entry per object type, and data under another name its own entry, which no check enforces beyond the option constants `FootprintTest` covers. - **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. - **Uninstall** runs `uninstall.php`, which calls the framework's uninstall engine over the footprint, with WooCommerce's order meta types, on every site whose `dws_tpl_delete_data` setting consents; a site that never consented keeps its data. Below the floors or with an incomplete build, a web request deletes the files, keeps the data and logs why, while WP-CLI fails and keeps both for a rerun. When the engine cannot remove everything, a web request ends with its reason before WordPress deletes the files, and the consent stays for the next uninstall. diff --git a/docs/recipes.md b/docs/recipes.md index 6770c83..fbaad15 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,6 +1,6 @@ # Recipes -These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces beyond the option constants `FootprintTest` covers. Options, transients and user meta under the plugin's `dws_tpl_` prefix are in the footprint already; meta on posts, terms, comments or orders needs its own `meta_prefixes` entry per object type. +These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `Plugin::FOOTPRINT`, which no check enforces beyond the option constants `FootprintTest` covers. Options, transients and user meta under the plugin's `dws_tpl_` prefix are in the footprint already; meta on posts, terms, comments or orders needs its own `meta_prefixes` entry per object type. - [A safeguard](#a-safeguard) - [A one-time notice](#a-one-time-notice) @@ -240,81 +240,62 @@ The notice's scope defaults to the current user and its message is HTML, so esca An options page adds a screen under a WordPress admin menu and stores each tab's fields in one option through the Settings API. WooCommerce settings belong on a WooCommerce tab, as `src/Settings.php` does. -Add the option, its field and its default, a reader that guards the field and the validator to `includes/options.php`, which stays PHP 7.4-parseable. The non-WooCommerce recipe below uses the same names, with the uninstall consent as a second field: +Add the option, its field, its default and a reader that guards the field to `src/Options.php`. The non-WooCommerce recipe below uses the same names, with the uninstall consent as a second field: ```php -/** - * The option that holds the settings page's fields, keyed by field id. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_OPTION_SETTINGS = 'dws_tpl_settings'; + /** + * The option that holds the settings page's fields, keyed by field id. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string SETTINGS = Plugin::PREFIX . '_settings'; -/** - * The settings field that holds the limit. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_SETTING_LIMIT = 'limit'; + /** + * The settings field that holds the limit. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string SETTING_LIMIT = 'limit'; -/** - * The value each settings field reads as while nothing valid is stored. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var array - */ -const DWS_TPL_SETTING_DEFAULTS = array( - DWS_TPL_SETTING_LIMIT => 1, -); + /** + * The value each settings field reads as while nothing valid is stored. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var array + */ + public const array SETTING_DEFAULTS = array( + self::SETTING_LIMIT => 1, + ); -/** - * Returns a settings field as an integer. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return int - */ -function dws_tpl_get_int_setting( string $field ): int { - $default = DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; - $stored = get_option( DWS_TPL_OPTION_SETTINGS ); - $stored = is_array( $stored ) && array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; - return filter_var( $stored, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); -} + // ... -/** - * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. - * - * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param mixed $submitted The submitted value. - * - * @return int|WP_Error - */ -function dws_tpl_validate_limit( $submitted ) { - $limit = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_FLOAT ) : false; - if ( false === $limit || $limit < 1 || $limit >= PHP_INT_MAX || floor( $limit ) !== $limit ) { - return new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); + /** + * Returns a settings field as an integer. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $field The field's id. + * + * @return int + */ + public static function get_int_setting( string $field ): int { + $default = self::SETTING_DEFAULTS[ $field ] ?? null; + $stored = get_option( self::SETTINGS ); + $stored = \is_array( $stored ) && \array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; + return \filter_var( $stored, \FILTER_VALIDATE_INT, \FILTER_NULL_ON_FAILURE ) ?? ( \is_int( $default ) ? $default : 0 ); } - - return (int) $limit; -} ``` -Add the page as a component, `src/SettingsPage.php`, and list it in `Plugin::COMPONENTS`: +Add the page as a component, `src/SettingsPage.php`, with its validator, and list it in `Plugin::COMPONENTS`. The page takes the plugin's identity, which the container provides: ```php register_hooks(); + new OptionsPage( $this->identity, Plugin::PREFIX, $this )->register_hooks(); } /** @@ -364,18 +362,18 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt array( 'slug' => 'general', 'title' => __( 'General', 'dws-plugin-template' ), - 'option' => DWS_TPL_OPTION_SETTINGS, + 'option' => Options::SETTINGS, 'fields' => array( array( 'type' => 'number', - 'id' => DWS_TPL_SETTING_LIMIT, + 'id' => Options::SETTING_LIMIT, 'title' => __( 'Limit', 'dws-plugin-template' ), - 'default' => (string) DWS_TPL_SETTING_DEFAULTS[ DWS_TPL_SETTING_LIMIT ], + 'default' => (string) Options::SETTING_DEFAULTS[ Options::SETTING_LIMIT ], 'custom_attributes' => array( 'min' => '1', 'step' => '1', ), - 'validate' => 'dws_tpl_validate_limit', + 'validate' => self::validate_limit( ... ), ), ), ), @@ -384,14 +382,39 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt } // endregion + + // region HELPERS + + /** + * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. + * + * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param mixed $submitted The submitted value. + * + * @return int|\WP_Error + */ + protected static function validate_limit( mixed $submitted ): int|\WP_Error { + $limit = \is_string( $submitted ) ? \filter_var( $submitted, \FILTER_VALIDATE_FLOAT ) : false; + if ( false === $limit || $limit < 1 || $limit >= \PHP_INT_MAX || \floor( $limit ) !== $limit ) { + return new \WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); + } + + return (int) $limit; + } + + // endregion } ``` -Read the limit with `dws_tpl_get_int_setting( DWS_TPL_SETTING_LIMIT )`, which returns the default until a valid limit is stored. The option is under the plugin's prefix, so the footprint covers it; a migration step that stores `DWS_TPL_SETTING_DEFAULTS` autoloaded saves the first request's query, as the non-WooCommerce recipe's step does. +Read the limit with `Options::get_int_setting( Options::SETTING_LIMIT )`, which returns the default until a valid limit is stored. The option is under the plugin's prefix, so the footprint covers it; a migration step that stores `Options::SETTING_DEFAULTS` autoloaded saves the first request's query, as the non-WooCommerce recipe's step does. ## A product data tab -A product data tab adds a panel to the product editor's Product data box, whose fields WooCommerce renders and saves as the product's meta. Add it as a component, `src/ProductData.php`, and list it in `Plugin::COMPONENTS`: +A product data tab adds a panel to the product editor's Product data box, whose fields WooCommerce renders and saves as the product's meta. Add it as a component, `src/ProductData.php`, which takes the plugin's identity, and list it in `Plugin::COMPONENTS`: ```php register_hooks(); + new ProductDataTab( $this->identity, Plugin::PREFIX, $this )->register_hooks(); } /** @@ -452,17 +492,17 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI } ``` -Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box. Post meta is not in the footprint until its type is, so declare the prefix for uninstall in `includes/footprint.php`: +Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box. Post meta is not in the footprint until its type is, so declare the prefix for uninstall in `Plugin::FOOTPRINT`, and add `meta_prefixes?: array>` to its `@var` shape: ```php -'meta_prefixes' => array( 'post' => array( '_' . Plugin::PREFIX . '_' ) ), +'meta_prefixes' => array( 'post' => array( '_' . self::PREFIX . '_' ) ), ``` A field with its own `render` callable returns its control as inline markup, escapes, names and describes it, and prints a hidden empty input before it unless the field is disabled. Without that input, a cleared control posts nothing, and the save leaves the stored value untouched. It also takes a `validate` callable that returns the value to store or a `WP_Error`, since the framework refuses to save a custom control's value that nothing validates. ## A form on an edit screen -An object form adds fields to an edit screen, such as a post's meta box, a user's profile or a term's form, and stores each field on the edited object. It reports a rejected value through the plugin's `AdminNotices`. Add it as a component, `src/PostNote.php`, and list it in `Plugin::COMPONENTS`: +An object form adds fields to an edit screen, such as a post's meta box, a user's profile or a term's form, and stores each field on the edited object. It reports a rejected value through the plugin's `AdminNotices`. Add it as a component, `src/PostNote.php`, which takes the plugin's identity and its notices, and list it in `Plugin::COMPONENTS`: ```php notices )->register_hooks(); + new ObjectForm( $this->identity, Plugin::PREFIX . '_note', $this, new PostMetaBox( array( 'post' ), 'side' ), $this->notices )->register_hooks(); } /** @@ -538,7 +581,7 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac } ``` -The host checks that the user can edit the post itself, and the form's `capability` is one more capability the user needs, checked without an object, so it names a primitive capability such as `edit_posts` rather than `edit_post`. The form verifies its own nonce before it asks for the definition on a save. Declare the meta for uninstall in `includes/footprint.php`, as the product data tab does. A field with its own `render` callable prints a hidden empty input before its control unless the field is disabled, and takes a `validate` callable, for the reasons that recipe gives. +The host checks that the user can edit the post itself, and the form's `capability` is one more capability the user needs, checked without an object, so it names a primitive capability such as `edit_posts` rather than `edit_post`. The form verifies its own nonce before it asks for the definition on a save. Declare the meta for uninstall in `Plugin::FOOTPRINT`, as the product data tab does. A field with its own `render` callable prints a hidden empty input before its control unless the field is disabled, and takes a `validate` callable, for the reasons that recipe gives. ## A section on WooCommerce's Payments tab @@ -600,14 +643,14 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti ), array( 'type' => 'checkbox', - 'id' => DWS_TPL_OPTION_PAYMENT_NOTES, + 'id' => Options::PAYMENT_NOTES, 'title' => __( 'Payment notes', 'dws-plugin-template' ), 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), - 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_PAYMENT_NOTES ] ), + 'default' => wc_bool_to_string( Options::DEFAULTS[ Options::PAYMENT_NOTES ] ), ), array( 'type' => 'multiselect', - 'id' => DWS_TPL_OPTION_PAYMENT_METHODS, + 'id' => Options::PAYMENT_METHODS, 'title' => __( 'Payment methods', 'dws-plugin-template' ), 'desc' => __( 'Add the note only to orders paid with these methods', 'dws-plugin-template' ), 'class' => 'wc-enhanced-select', @@ -666,45 +709,47 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti } ``` -WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `dws_tpl_get_bool_option()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `includes/options.php` with its default in `DWS_TPL_OPTION_DEFAULTS`, `false` for the checkbox and an empty array for the multiselect, whose `@var` then allows `list`, and the list reader beside the other typed readers: +WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `Options::get_bool()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `src/Options.php` with its default in `DEFAULTS`, `false` for the checkbox and an empty array for the multiselect, whose `@var` then allows `list`, and the list reader beside the other typed readers: ```php -/** - * The option that holds 'yes' where the site adds a note to each order when its payment completes. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_OPTION_PAYMENT_NOTES = 'dws_tpl_payment_notes'; + /** + * The option that holds 'yes' where the site adds a note to each order when its payment completes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string PAYMENT_NOTES = Plugin::PREFIX . '_payment_notes'; -/** - * The option that holds the payment methods whose orders get the payment note. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_OPTION_PAYMENT_METHODS = 'dws_tpl_payment_methods'; + /** + * The option that holds the payment methods whose orders get the payment note. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string PAYMENT_METHODS = Plugin::PREFIX . '_payment_methods'; -/** - * Returns an option as a list of strings, keeping the stored strings in order. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $key The option's key. - * - * @return list - */ -function dws_tpl_get_list_option( string $key ): array { - $default = dws_tpl_get_option_default( $key ); - $stored = get_option( $key, $default ); - $stored = is_array( $stored ) ? $stored : $default; - return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); -} + // ... + + /** + * Returns an option as a list of strings, keeping the stored strings in order. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return list + */ + public static function get_list( string $key ): array { + $default = self::get_default( $key ); + $stored = get_option( $key, $default ); + $stored = \is_array( $stored ) ? $stored : $default; + return \is_array( $stored ) ? \array_values( \array_filter( $stored, \is_string( ... ) ) ) : array(); + } ``` WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays truthy, as gateway ids are. @@ -713,18 +758,18 @@ WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays t A recurring job runs a hook at an interval through the plugin's scheduler, which uses Action Scheduler where it is loaded and WP-Cron otherwise. Either can run an invocation twice or skip one, so the job's callback leaves its data consistent after a duplicate or a missed run. -Add the repair gate's option to `includes/options.php`, with its default `0` in `DWS_TPL_OPTION_DEFAULTS`: +Add the repair gate's option to `src/Options.php`, with its default `0` in `DEFAULTS`: ```php -/** - * The option that holds when the plugin last ensured its recurring jobs, which lets a request skip ensuring them for an hour. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_OPTION_RECURRING_ENSURED_AT = 'dws_tpl_recurring_ensured_at'; + /** + * The option that holds when the plugin last ensured its recurring jobs, which lets a request skip ensuring them for an hour. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string RECURRING_ENSURED_AT = Plugin::PREFIX . '_recurring_ensured_at'; ``` Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, and again when Action Scheduler asks every day. Writing the time first narrows the window in which two requests both ensure it, and `ensure_recurring()` repairs a duplicate chain on its next pass: @@ -759,7 +804,7 @@ final class Pruning implements ComponentInterface { * * @var string */ - public const string HOOK = Plugin::PREFIX . '/records/prune'; + public const string HOOK = 'dws_plugin_template/records/prune'; // endregion @@ -809,12 +854,12 @@ final class Pruning implements ComponentInterface { * @return void */ public function repair_schedule(): void { - $ensured_at = dws_tpl_get_int_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT ); + $ensured_at = Options::get_int( Options::RECURRING_ENSURED_AT ); if ( \time() - $ensured_at < HOUR_IN_SECONDS ) { return; } - update_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT, \time(), true ); + update_option( Options::RECURRING_ENSURED_AT, \time(), true ); $this->ensure_schedule(); } @@ -857,35 +902,26 @@ final class Pruning implements ComponentInterface { ``` Then: -- Add `Pruning::HOOK` to `cron_hooks` in `includes/footprint.php`, importing the class as the file imports `Plugin`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. -- Delete the repair gate's option on activation, so the next request ensures the jobs: add `register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' );` to `functions.php` beside the deactivation hook, and a `dws_tpl_activate()` that calls `delete_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT )`. A plugin that also takes the rewrite-rules recipe keeps one `dws_tpl_activate()` that does both. +- Add `Pruning::HOOK` to `cron_hooks` in `Plugin::FOOTPRINT`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. +- Delete the repair gate's option on activation, so the next request ensures the jobs: register an activation closure in `functions.php` beside the deactivation hook, as the rewrite-rules recipe shows, that calls `delete_option( Options::RECURRING_ENSURED_AT )`. A plugin that also takes the rewrite-rules recipe keeps one activation closure that does both. - Change an interval in code only: `ensure_recurring()` replaces a schedule whose interval differs on the next pass of the repair gate. A "run now" schedules the same hook with an argument that sets it apart, or calls `prune()` directly. ## Rewrite rules after activation WordPress builds its rewrite rules from the post types and taxonomies registered on `init`. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so flushing there would drop the plugin's rules. Delete the stored rules instead: the next request rebuilds them once the plugin has registered its types. -In `functions.php`, beside the deactivation hook: +In `functions.php`, beside the deactivation hook, as a static closure like the plugin's other hooks there: ```php -register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' ); - -// ... - -/** - * Lets the next request rebuild the site's rewrite rules with the plugin's post types and taxonomies. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @return void - */ -function dws_tpl_activate(): void { - delete_option( 'rewrite_rules' ); -} +register_activation_hook( + DWS_TPL_FILE, + static function (): void { + delete_option( 'rewrite_rules' ); + } +); ``` -A plugin that also takes the recurring-jobs recipe keeps one `dws_tpl_activate()` that does both. Add the same `delete_option( 'rewrite_rules' )` to `dws_tpl_deactivate()`, so the next request rebuilds the rules without them. Both act on the current site only, as WordPress's own activation does; a network activation leaves the other sites' rules until they next rebuild them. +A plugin that also takes the recurring-jobs recipe keeps one activation closure that does both. Add the same `delete_option( 'rewrite_rules' )` to the deactivation closure, so the next request rebuilds the rules without them. Both act on the current site only, as WordPress's own activation does; a network activation leaves the other sites' rules until they next rebuild them. ## A script module @@ -1042,14 +1078,25 @@ In `phpstan.safeguards.neon`, delete the whole `scanFiles` key, which lists only 3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers, and in `tests/Unit/EntryHeadersTest.php` the row that ties `WC tested up to` to the locked WooCommerce. -4. In `functions.php`, delete the `FeaturesUtil` import, the `before_woocommerce_init` hook and `dws_tpl_declare_compatibility()`, so the hooks after the includes loop read: +4. In `functions.php`, delete the `FeaturesUtil` import and the `before_woocommerce_init` closure, so the hooks after the includes loop read: ```php // ... the includes loop ... // The hooks come last, so an include that throws leaves nothing of the plugin hooked. -add_action( 'plugins_loaded', 'dws_tpl_boot', 15 ); -register_deactivation_hook( DWS_TPL_FILE, 'dws_tpl_deactivate' ); +add_action( + 'plugins_loaded', + static function (): void { + dws_tpl_plugin()->boot(); + }, + 15 +); +register_deactivation_hook( + DWS_TPL_FILE, + static function (): void { + dws_tpl_plugin()->deactivate(); + } +); ``` 5. In `src/Plugin.php`, log through core's `ErrorLogLogger`, which drops records below warning unless `WP_DEBUG` is on, so `DWS_TPL_DEBUG` goes. Compose without the WooCommerce check, and leave the greeting out of `COMPONENTS`: @@ -1076,9 +1123,10 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra // ... public function __construct() { - $this->logger = new ErrorLogLogger( self::SLUG ); - $this->scheduler = new Scheduler( self::PREFIX ); - $this->boundary = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger ); + $this->identity = new PluginIdentity( DWS_TPL_FILE, self::SLUG, self::PREFIX ); + $this->logger = new ErrorLogLogger( $this->identity ); + $this->scheduler = new Scheduler( $this->identity ); + $this->boundary = new PluginBoot( $this->identity, $this->logger ); } // ... @@ -1093,10 +1141,10 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra */ protected function compose(): bool { $this->scheduler->register_hooks(); - $this->migrations = new MigrationRunner( DWS_TPL_FILE, DWS_TPL_OPTION_REVISION, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler ); + $this->migrations = new MigrationRunner( $this->identity, Migrations::get_steps(), $this->logger, $this->scheduler ); $this->migrations->register_hooks(); - $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->identity, $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); return true; } @@ -1114,78 +1162,79 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra ```php array( 'type' => 'checkbox', - 'id' => DWS_TPL_SETTING_DELETE_DATA, + 'id' => Options::SETTING_DELETE_DATA, 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), ), ``` -8. In `includes/options.php`, delete the greeting's three keys with their defaults, text and validator, and `DWS_TPL_OPTION_DELETE_DATA`. The typed option readers stay for a standalone option, such as the recurring-jobs recipe's, with its default in `DWS_TPL_OPTION_DEFAULTS`; while the plugin has none, delete them with `DWS_TPL_OPTION_DEFAULTS`, `dws_tpl_get_option_text()` and `dws_tpl_get_option_default()`, since PHPStan refuses a lookup in an empty array. Add the settings-page recipe's settings option, field, default, reader and validator, then the consent field, its default `false` in `DWS_TPL_SETTING_DEFAULTS`, whose `@var` then reads `array`, and a reader for it: +8. In `src/Options.php`, delete the greeting's three keys with their defaults, and `DELETE_DATA`. The typed option readers stay for a standalone option, such as the recurring-jobs recipe's, with its default in `DEFAULTS`; while the plugin has none, delete them with `DEFAULTS` and `get_default()`, since PHPStan refuses a lookup in an empty array. Add the settings-page recipe's settings option, field, default and reader, then the consent field, its default `false` in `SETTING_DEFAULTS`, whose `@var` then reads `array`, and a reader for it: ```php -/** - * The settings field that holds 'yes' where the site consents to uninstall removing the plugin's data. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @var string - */ -const DWS_TPL_SETTING_DELETE_DATA = 'delete_data'; + /** + * The settings field that holds 'yes' where the site consents to uninstall removing the plugin's data. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string SETTING_DELETE_DATA = 'delete_data'; -/** - * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return bool - */ -function dws_tpl_get_bool_setting( string $field ): bool { - $default = DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; - $stored = get_option( DWS_TPL_OPTION_SETTINGS ); - $stored = is_array( $stored ) && array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; - return filter_var( $stored, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); -} + // ... + + /** + * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $field The field's id. + * + * @return bool + */ + public static function get_bool_setting( string $field ): bool { + $default = self::SETTING_DEFAULTS[ $field ] ?? null; + $stored = get_option( self::SETTINGS ); + $stored = \is_array( $stored ) && \array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; + return \filter_var( $stored, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); + } ``` -9. In `includes/migrations.php`, the first step stores the defaults as they are. A stored `false` renders as an unchecked checkbox, the readers guard it, and uninstall consents only on `'yes'`: +9. In `src/Migrations.php`, the first step stores the defaults as they are. A stored `false` renders as an unchecked checkbox, the readers guard it, and uninstall consents only on `'yes'`: ```php -/** - * Stores the settings' defaults, autoloaded, so a site that never saved the settings page reads them without a query. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @throws RuntimeException Thrown when the defaults could not be stored. - * - * @return void - */ -function dws_tpl_add_settings(): void { - if ( false === get_option( DWS_TPL_OPTION_SETTINGS ) && ! add_option( DWS_TPL_OPTION_SETTINGS, DWS_TPL_SETTING_DEFAULTS, '', true ) ) { - throw new RuntimeException( 'The option ' . DWS_TPL_OPTION_SETTINGS . ' could not be stored.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + /** + * Stores the settings' defaults, autoloaded, so a site that never saved the settings page reads them without a query. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @throws \RuntimeException Thrown when the defaults could not be stored. + * + * @return void + */ + protected static function add_settings(): void { + if ( false === get_option( Options::SETTINGS ) && ! add_option( Options::SETTINGS, Options::SETTING_DEFAULTS, '', true ) ) { + throw new \RuntimeException( 'The option ' . Options::SETTINGS . ' could not be stored.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } } -} ``` -10. In `uninstall.php`, delete the `MetaTypes` import and `$dws_tpl_meta_types`, and take consent from the settings row's `delete_data` key: +10. In `uninstall.php`, delete the `MetaTypes` import and `$dws_tpl_meta_types`, and take consent from the settings row's `delete_data` key, read inside the `try` as the footprint is: ```php - $dws_tpl_footprint = dws_tpl_get_footprint(); + $dws_tpl_footprint = Plugin::FOOTPRINT; + $dws_tpl_consent = array( + 'option' => Options::SETTINGS, + 'key' => Options::SETTING_DELETE_DATA, + ); // ... Uninstaller::run( - $dws_tpl_file, + $dws_tpl_identity, $dws_tpl_footprint, - array( - 'consent' => array( - 'option' => DWS_TPL_OPTION_SETTINGS, - 'key' => DWS_TPL_SETTING_DELETE_DATA, - ), - ) + array( 'consent' => $dws_tpl_consent ) ); ``` @@ -1202,22 +1251,22 @@ function dws_tpl_add_settings(): void { 12. Adapt the tests: - Delete `tests/EndToEnd/without-woocommerce.spec.ts` and `tests/Support/OrderStorageTrait.php`. -- `OptionsTest` reads through `dws_tpl_get_bool_setting()` and `dws_tpl_get_int_setting()` from a `DWS_TPL_OPTION_SETTINGS` row; a malformed limit reads its default 1; the validator rows call `dws_tpl_validate_limit()`, which returns integers; the option readers' rows go if step 8 deleted the readers. -- `MigrationsTest` expects `DWS_TPL_SETTING_DEFAULTS` stored autoloaded, a stored row kept as it is, and the throw when that option's `INSERT` fails. -- `UninstallTest` consents through `array( DWS_TPL_SETTING_DELETE_DATA => 'yes' )`, seeds and asserts no Action Scheduler action, and expects the defaults stored again afterwards. -- `PartialDeployTest`'s missing-engine row seeds and asserts consent through `array( DWS_TPL_SETTING_DELETE_DATA => 'yes' )` in `DWS_TPL_OPTION_SETTINGS`, and restores the row it read. -- `MultisiteTest` reads the second site's consent from its `DWS_TPL_OPTION_SETTINGS` row. +- `OptionsTest` reads through `Options::get_bool_setting()` and `Options::get_int_setting()` from an `Options::SETTINGS` row, and a malformed limit reads its default 1; the option readers' rows go if step 8 deleted the readers. +- `MigrationsTest` expects `Options::SETTING_DEFAULTS` stored autoloaded, a stored row kept as it is, and the throw when that option's `INSERT` fails. +- `UninstallTest` consents through `array( Options::SETTING_DELETE_DATA => 'yes' )`, seeds and asserts no Action Scheduler action, and expects the defaults stored again afterwards. +- `PartialDeployTest`'s missing-engine row seeds and asserts consent through `array( Options::SETTING_DELETE_DATA => 'yes' )` in `Options::SETTINGS`, and restores the row it read. +- `MultisiteTest` reads the second site's consent from its `Options::SETTINGS` row. - `PluginBootTest` expects nothing in `$plugin->kernel->skipped`, and drops the `DWS_TPL_DEBUG` row with its `get_threshold()` helper and the `WooCommerceLogger` and `RunInSeparateProcess` imports. -- `SettingsTest` builds `new OptionsPage( Plugin::PREFIX, new SettingsPage() )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: +- `SettingsTest` builds `new OptionsPage( $identity, Plugin::PREFIX, new SettingsPage( $identity ) )` over `new PluginIdentity( DWS_TPL_FILE, Plugin::SLUG, Plugin::PREFIX )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: ```php // options.php saves a submission through update_option(), whose filter the page hooks for its own option page only. protected function save( array $posted ): void { $GLOBALS['pagenow'] = 'options.php'; - $_REQUEST['option_page'] = DWS_TPL_OPTION_SETTINGS; + $_REQUEST['option_page'] = Options::SETTINGS; try { - update_option( DWS_TPL_OPTION_SETTINGS, $posted ); + update_option( Options::SETTINGS, $posted ); } finally { unset( $GLOBALS['pagenow'], $_REQUEST['option_page'] ); } diff --git a/tests/README.md b/tests/README.md index 48d11a2..70f9e11 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's `WC tested up to` against the locked WooCommerce | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, the `@api` tag on every global function and constant, every directory's stub, and the entry's `Tested up to` and `WC tested up to` against the locked WordPress and WooCommerce | | Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, the safeguard recipe's own test, and the boot and settings on a second site of a network | | End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, the example block inserted from the editor's command palette and published in its Outlined style, and the plugin with WooCommerce deactivated | @@ -58,7 +58,7 @@ composer quality-check - **Tests** runs Unit once, and Integration, its `multisite` group and the end-to-end suite once per WordPress branch, from the entry file's `Requires at least` up to the newest release, which WordPress's version API lists, so a new release joins without an edit. - **Tests (nightly)** runs Integration, its `multisite` group and the end-to-end suite on WordPress trunk every day, outside pull requests. - **Release** builds the zip on every pull request as a tag would, runs Plugin Check and the archive checks, then activates the zip on a site and runs the end-to-end suite against it; it publishes only on a tag, and never on the template. -- **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `includes/`, the script, style and type lints, the `block.json` schema and the changelog fragments, and on the template it rehearses the fill-in at the longest prefix it accepts, then lints, unit-tests and builds the filled-in plugin. +- **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `Safeguards.php`, the script, style and type lints, the `block.json` schema and the changelog fragments, and on the template it rehearses the fill-in at the longest prefix it accepts, then lints, unit-tests and builds the filled-in plugin. ## Conventions From 84d7f5a8964676741dd3cd6a3b797004dfdefa63 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:11:42 +0100 Subject: [PATCH 14/19] Rehearse the fill-in at the longest prefix the framework accepts Quality rehearsed the fill-in at the longest prefix the script accepted and checked it against PHPCS alone, while the framework's PluginIdentity refuses a prefix longer than 32 characters. No step of the rehearsal built an identity, so a script that accepted more than the framework would seed a plugin that throws when it boots or uninstalls. Expect the plugin's slug and prefix to make an identity the framework accepts, and rehearse at a 28-character segment, the longest the identity leaves after dws_, which the script's cap of 22 still refuses. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/quality.yml | 4 ++-- tests/Unit/FootprintTest.php | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a75f850..6453d04 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -87,8 +87,8 @@ jobs: DESCRIPTION: 'Acme Orders keeps a store''s "orders" in one place, C:\ included.' SLUG: acme-orders NS: AcmeOrders - # The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing. - PREFIX: dws_acmeextraordinarysales + # The rehearsal fills in the longest prefix the script accepts, so its Unit run fails once the framework refuses it. + PREFIX: dws_acmeextraordinarysalesorders PORT: '8831' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index 7e1efb7..2a84712 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\PluginTemplate\Options; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\PluginIdentity; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; @@ -17,6 +18,11 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa self::assertMatchesRegularExpression( '/^dws_[a-z0-9]+$/', Plugin::PREFIX ); } + // Quality rehearses the fill-in at the longest prefix it accepts, which this row holds to the framework's own bound. + public function test_the_prefix_makes_an_identity_the_framework_accepts(): void { + self::assertSame( Plugin::PREFIX, new PluginIdentity( __FILE__, Plugin::SLUG, Plugin::PREFIX )->prefix ); + } + public function test_every_kind_is_one_the_uninstall_engine_removes(): void { $doc = (string) new \ReflectionClass( Uninstaller::class )->getDocComment(); self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); From c562a8a89a8683a1945017f75ce8f7e0012bf149 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 21:20:26 +0100 Subject: [PATCH 15/19] Cap the fill-in's prefix at what the framework's identity accepts The fill-in capped the prefix's segment at 22 characters, because a longer one pushed the template's longest option constants past PHPCS's alignment. Those names are now class constants built from Plugin::PREFIX, so the bound that holds is the framework's PluginIdentity, which takes a prefix of at most 32 characters. Accept a segment of up to 28 characters, the most that identity leaves after dws_, and give its bound as the reason. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 8 ++++---- .github/workflows/fill-in-scaffold.yml | 2 +- README.md | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 289e45b..a5f3848 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -22,10 +22,10 @@ done # A namespace that is a reserved word, such as List, parses from PHP 8.0 only, which the fill-in job's PHP 7.4 syntax # check refuses. [[ $NS =~ ^[A-Za-z][A-Za-z0-9]*$ ]] || fail 'php-namespace must match ^[A-Za-z][A-Za-z0-9]*$.' -# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 22 characters -# the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks, -# and the framework's admin notices refuse a network or hidden segment. -[[ $PREFIX =~ ^dws_[a-z0-9]{1,22}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,22}$ and must not be dws_network or dws_hidden.' +# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; the framework's +# identity takes a prefix of at most 32 characters, which Quality's rehearsal at the cap checks, and its admin notices +# refuse a network or hidden segment. +[[ $PREFIX =~ ^dws_[a-z0-9]{1,28}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,28}$ and must not be dws_network or dws_hidden.' # The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the # next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses. [[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] || diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 49a94f7..318deb0 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -20,7 +20,7 @@ on: required: true type: string prefix: - description: 'Prefix of the global functions, constants and stored data, dws_ plus one segment, such as "dws_ao"' + description: 'Prefix of the global functions, constants and stored data, dws_ plus one segment of up to 28 lowercase letters and digits, such as "dws_ao"' required: true type: string wp-env-port: diff --git a/README.md b/README.md index dff5779..fbeed01 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Then turn on private vulnerability reporting and Dependabot alerts in the new re ## Naming - A WooCommerce plugin's display name reads `X for WooCommerce`, as WooCommerce's trademark guidelines ask, and its slug ends in `-for-woocommerce`. -- The prefix is `dws_` and one segment of up to 22 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's `{prefix}_` starts with another's and the template's longest constants still pass PHPCS; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`. +- The prefix is `dws_` and one segment of up to 28 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's `{prefix}_` starts with another's and the framework's identity, which takes at most 32 characters, accepts it; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`. - The namespace is flat: `DeepWebSolutions\\`, with no segment in between. - The repository is `wc-` for a WooCommerce plugin and `wp-` otherwise, as the plugin mirrors are named, and the fill-in names the Composer package `/` and the npm package `@/` after it. - Each plugin takes its own three consecutive wp-env ports, for its development, tests and multisite sites, apart from 8888 and 8889, which wp-env uses by default, and the template's own 8811, 8812 and 8813. From 7861c2c3b1464810fb77e64197c0659970d01f58 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:52:11 +0100 Subject: [PATCH 16/19] Expect the greeting's state as the plugin's API Another plugin that needs one of the template's settings can only read the stored option by its key, and the template shows no function a plugin would offer for that, although an integration may need one (X rulings 384, 391 and 413). GreetingFeatureTest expects dws_tpl_is_greeting_enabled() to report the stored toggle, on, off and unset, as the greeting's gate reads it. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/Greeting/GreetingFeatureTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 7fc4b09..3893288 100644 --- a/tests/Integration/Greeting/GreetingFeatureTest.php +++ b/tests/Integration/Greeting/GreetingFeatureTest.php @@ -28,10 +28,11 @@ public static function stored_toggles(): array { } #[DataProvider( 'stored_toggles' )] - public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?string $stored, bool $expected ): void { + public function test_the_greeting_loads_and_the_api_reports_it_on_only_where_a_site_switches_it_on( ?string $stored, bool $expected ): void { \is_null( $stored ) ? delete_option( Options::GREETING_ENABLED ) : update_option( Options::GREETING_ENABLED, $stored ); self::assertSame( $expected, GreetingFeature::should_load() ); + self::assertSame( $expected, dws_tpl_is_greeting_enabled() ); } #[RunInSeparateProcess] From 225febb378bffd9e23a03b6ab96f44ba8b647f1a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:52:30 +0100 Subject: [PATCH 17/19] Expose whether the greeting is on as the plugin's API The template gave another plugin no way to read one of its settings but the stored option itself, and showed no function of the kind a plugin offers when an integration needs a setting (X rulings 384, 391 and 413). includes/greeting.php declares dws_tpl_is_greeting_enabled(), tagged @api, which reads the toggle through Options, as the greeting's gate does, and adds no filter of its own. Assisted-by: Claude Code:claude-opus-5-5 --- includes/greeting.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 includes/greeting.php diff --git a/includes/greeting.php b/includes/greeting.php new file mode 100644 index 0000000..d75b81a --- /dev/null +++ b/includes/greeting.php @@ -0,0 +1,29 @@ + Date: Fri, 13 Nov 2026 22:52:59 +0100 Subject: [PATCH 18/19] Describe the greeting's integration point in the docs The recipes showed no way to offer a setting to another plugin, the architecture, the scaffold notes and the agent guide listed only the peer getters in includes/, and the add-on convention named only those getters, so a plugin built from the template would neither find the new function nor know to delete it with the greeting (X ruling 413). A recipe adds one narrowly named, typed @api function per setting an integration needs, delegating to Options; the add-on convention reads a setting through such a function, else through the base plugin's options; the architecture and AGENTS.md's layout name includes/greeting.php, and the scaffold README and the without-WooCommerce recipe delete it with the greeting. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 3 ++- README.scaffold.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/recipes.md | 29 ++++++++++++++++++++++++++++- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8c6a44e..5b5af73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,7 @@ src/ the plugin's classes, each named for its concept ├── Blocks.php the blocks from blocks/build/blocks-manifest.php and the block-editor script └── Greeting/ the greeting: GreetingFeature, gated on Options::GREETING_ENABLED, and its AccountDashboardGreeting child, which owns the default message includes/ more @api functions and constants, grouped by topic, loaded in name order, _-prefixed files skipped +├── greeting.php whether the greeting is on, for an integration that needs the setting └── metadata.php the slug, name and version getters, the plugin's surface for a peer plugin blocks/src/, assets/src/ TypeScript, SCSS and each block's render.php, built into blocks/build/ and assets/build/ (untracked) models/, templates/ empty extension points @@ -53,7 +54,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 1. **Placement.** The global namespace holds only the plugin's contract: `functions.php` and `includes/` declare its `@api` functions and constants, and `functions.php` registers its hooks as static closures. Every other symbol is a member of a final class in `src/` named for its concept, `protected` wherever only its class uses it: the composition root, which holds the footprint, a component, `Options`, `Migrations` or another class that registers no hooks. No namespace or folder is named "Internal". Container definitions are composition and stay in `Plugin::get_definitions()`; a component takes the plugin's identity, or a service such as `Assets`, by constructor. 2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. -4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. +4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, and a setting through an `@api` function where the base plugin offers one, else through the base plugin's options. It calls the base plugin's functions from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** Options are read only through `Options`' typed readers, which translate nothing, safeguards excepted; a translated default belongs to the component that shows it, as the greeting's message does. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta, but for scheduled jobs, whose hooks take the hook root as every hook does and whose Action Scheduler group is the bare `dws_tpl`. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else, such as a scheduled hook, its own entry. 7. **Safeguards** live in `Safeguards.php`, a final class whose `register()` hooks each safeguard as a static closure, which no code can remove by name, so a safeguard relaxes only through its own filter, through the protected `is_relaxed()` (`docs/recipes.md`); only what other classes read is public. The entry requires the class by path before the floor check, so it parses on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one closure, which `register()` runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the class and the entry, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. diff --git a/README.scaffold.md b/README.scaffold.md index b1f5e91..a99cc3b 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -55,7 +55,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas | Part | Where it lives | Removing it | | --- | --- | --- | -| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php` with its validator, and the `GREETING_ENABLED`, `GREETING` and `GREETING_ORDERS` keys in `src/Options.php` | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php` with `validate_greeting_orders()`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, their lines in `Migrations::add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. | +| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php` with its validator, the `GREETING_ENABLED`, `GREETING` and `GREETING_ORDERS` keys in `src/Options.php`, and `includes/greeting.php`, the function through which another plugin reads whether it is on | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, `includes/greeting.php`, the Greeting section of `src/Settings.php` with `validate_greeting_orders()`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, their lines in `Migrations::add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. | | The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` and rendered by its `render.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, `BlocksTest`'s block rows, and the editor script's command, which inserts the block, with `tests/EndToEnd/editor.spec.ts`. | | The editor script | `assets/src/editor.ts`, a command in the editor's command palette that inserts the example block, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `@wordpress/commands` and `@wordpress/data` from `package.json`'s `devDependencies`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, `BlocksTest`'s editor row and `tests/EndToEnd/editor.spec.ts`. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. | | `models/` and `templates/` | Empty extension points, for classes on Composer's classmap and for template partials | Delete the folders the plugin never fills, and `models/` from `composer.json`'s `autoload.classmap` and `phpstan.dist.neon`'s `paths`, since Composer and PHPStan both fail on a missing folder. | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e5a1a3c..48afdb0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ The request that activates the plugin does not boot it, because WordPress includ ## Where code lives - `src/` holds the plugin's classes, each final and named for its concept: the composition root; the components, which implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`; and classes that register no hooks, `Options` and `Migrations`. A component takes the plugin's identity and ungated services in its constructor, such as the logger, the scheduler, `AdminNotices` or `Assets`, which `Plugin::get_definitions()` provides; a component that builds a settings page, product data tab or form passes the identity to it. -- `includes/` extends `functions.php` with more of the plugin's `@api` functions and constants, grouped by topic: the slug, name and version getters a peer plugin reads (`metadata.php`). The global namespace holds nothing else, so the plugin supports only what it tags as API. +- `includes/` extends `functions.php` with more of the plugin's `@api` functions and constants, grouped by topic: the slug, name and version getters a peer plugin reads (`metadata.php`), and whether the greeting is on (`greeting.php`). The global namespace holds nothing else, so the plugin supports only what it tags as API. - `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`, and each block's `render.php`, which it copies beside the block's build and WordPress runs on each request, so the block's text follows the visitor's language; the framework's `Assets` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`. - `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials. diff --git a/docs/recipes.md b/docs/recipes.md index fbaad15..9051e62 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -11,6 +11,7 @@ These are the changes most plugins built from this template make, each as the fi - [Recurring jobs](#recurring-jobs) - [Rewrite rules after activation](#rewrite-rules-after-activation) - [A script module](#a-script-module) +- [A setting another plugin reads](#a-setting-another-plugin-reads) - [Renaming a public hook](#renaming-a-public-hook) - [Distribution through wordpress.org](#distribution-through-wordpressorg) - [Without WooCommerce](#without-woocommerce) @@ -956,6 +957,32 @@ Build the blocks' modules with their scripts, by adding `--experimental-modules` The build writes `view.js` with a `view.asset.php` whose `type` is `module`, and `src/Blocks.php`'s registration from the manifest picks it up. +## A setting another plugin reads + +Another plugin can always read the plugin's options through `get_option()`, since their names are contract. Where an integration needs one setting as the plugin reads it, add one `@api` function for it to a file in `includes/` named for its topic, which imports `Options`, as `includes/greeting.php` does. For the settings page's limit: + +```php +/** + * Returns the limit the plugin applies. + * + * @api + * + * @since 1.0.0 + * @version 1.0.0 + * + * @return int + */ +function dws_tpl_get_limit(): int { + return Options::get_int_setting( Options::SETTING_LIMIT ); +} +``` + +- Name the function for the one thing it answers and type its return. A generic `dws_tpl_get_option( $key )` would make every setting's type and default contract at once. +- Delegate to `Options`, which the plugin's own code keeps calling, so the read has one implementation. +- Apply a filter only where the plugin's own code reads the setting through the same filter, so both read the same value. +- Another plugin calls the function from `plugins_loaded` on and behind `function_exists()`, as AGENTS.md's add-on convention asks of every `@api` function, since the plugin may load later or stay active below its floors without defining it. +- Tag the function `@api`, which `GlobalsTest` checks. It is contract from its first release, so a break of it ships as the next major, as AGENTS.md's Contract section says. + ## Renaming a public hook A public hook's name is part of the plugin's contract, as its arguments, its options and its REST routes are. To rename one in a released plugin: @@ -1150,7 +1177,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra } ``` -6. Delete the greeting: `src/Greeting/`, `tests/Integration/Greeting/` and `assets/src/greeting.scss`, then its entries in `package.json`: +6. Delete the greeting: `src/Greeting/`, `includes/greeting.php`, `tests/Integration/Greeting/` and `assets/src/greeting.scss`, then its entries in `package.json`: ```json "build:assets": "wp-scripts build editor=./assets/src/editor.ts --output-path=assets/build", From 52a2f97b293d95e0cdb8579a92780b840c7ebac9 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Fri, 13 Nov 2026 22:53:53 +0100 Subject: [PATCH 19/19] Write each stored option name whole The option keys and the network option the footprint names were built from Plugin::PREFIX, so no stored name appeared whole in the code and a search for a contract string found nothing, while one edit to the prefix silently renamed every stored key (X ruling 414). Options' keys and the footprint's network option hold their whole names, as do the recipes' option keys and their two meta keys, which become constants on the classes that store them; the footprint's prefixes stay built from the prefix. FootprintTest already fails a key outside the prefix family. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 42 +++++++++++++++++++++++++++++++++++------- src/Options.php | 8 ++++---- src/Plugin.php | 2 +- 3 files changed, 40 insertions(+), 12 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index 9051e62..eca82ae 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -252,7 +252,7 @@ Add the option, its field, its default and a reader that guards the field to `sr * * @var string */ - public const string SETTINGS = Plugin::PREFIX . '_settings'; + public const string SETTINGS = 'dws_tpl_settings'; /** * The settings field that holds the limit. @@ -436,6 +436,20 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerc * @version 1.0.0 */ final class ProductData implements ComponentInterface, ProductDataTabDefinitionInterface { + // region FIELDS AND CONSTANTS + + /** + * The meta key that holds the box a product ships in. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + public const string BOX = '_dws_tpl_box'; + + // endregion + // region MAGIC METHODS /** @@ -480,7 +494,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI 'fields' => array( array( 'type' => 'text', - 'id' => '_' . Plugin::PREFIX . '_box', + 'id' => self::BOX, 'label' => __( 'Box', 'dws-plugin-template' ), ), ), @@ -493,7 +507,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI } ``` -Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box. Post meta is not in the footprint until its type is, so declare the prefix for uninstall in `Plugin::FOOTPRINT`, and add `meta_prefixes?: array>` to its `@var` shape: +Read the stored value with `ProductMeta::get_value( $product, ProductData::BOX )`. The leading underscore hides the key from WordPress's Custom Fields box. Post meta is not in the footprint until its type is, so declare the prefix for uninstall in `Plugin::FOOTPRINT`, and add `meta_prefixes?: array>` to its `@var` shape: ```php 'meta_prefixes' => array( 'post' => array( '_' . self::PREFIX . '_' ) ), @@ -526,6 +540,20 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\E * @version 1.0.0 */ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterface { + // region FIELDS AND CONSTANTS + + /** + * The meta key that holds a post's editorial note. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ + protected const string NOTE = '_dws_tpl_note'; + + // endregion + // region MAGIC METHODS /** @@ -571,7 +599,7 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac 'fields' => array( array( 'type' => 'textarea', - 'id' => '_' . Plugin::PREFIX . '_note', + 'id' => self::NOTE, 'title' => __( 'Note', 'dws-plugin-template' ), ), ), @@ -721,7 +749,7 @@ WooCommerce stores each field in its own option under its id, flat rather than i * * @var string */ - public const string PAYMENT_NOTES = Plugin::PREFIX . '_payment_notes'; + public const string PAYMENT_NOTES = 'dws_tpl_payment_notes'; /** * The option that holds the payment methods whose orders get the payment note. @@ -731,7 +759,7 @@ WooCommerce stores each field in its own option under its id, flat rather than i * * @var string */ - public const string PAYMENT_METHODS = Plugin::PREFIX . '_payment_methods'; + public const string PAYMENT_METHODS = 'dws_tpl_payment_methods'; // ... @@ -770,7 +798,7 @@ Add the repair gate's option to `src/Options.php`, with its default `0` in `DEFA * * @var string */ - public const string RECURRING_ENSURED_AT = Plugin::PREFIX . '_recurring_ensured_at'; + public const string RECURRING_ENSURED_AT = 'dws_tpl_recurring_ensured_at'; ``` Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, and again when Action Scheduler asks every day. Writing the time first narrows the window in which two requests both ensure it, and `ensure_recurring()` repairs a duplicate chain on its next pass: diff --git a/src/Options.php b/src/Options.php index afc95cf..07e6c9f 100644 --- a/src/Options.php +++ b/src/Options.php @@ -21,7 +21,7 @@ final class Options { * * @var string */ - public const string DELETE_DATA = Plugin::PREFIX . '_delete_data'; + public const string DELETE_DATA = 'dws_tpl_delete_data'; /** * The option that holds the greeting. @@ -31,7 +31,7 @@ final class Options { * * @var string */ - public const string GREETING = Plugin::PREFIX . '_greeting'; + public const string GREETING = 'dws_tpl_greeting'; /** * The option that holds 'yes' where the site greets returning customers on their account dashboard. @@ -41,7 +41,7 @@ final class Options { * * @var string */ - public const string GREETING_ENABLED = Plugin::PREFIX . '_greeting_enabled'; + public const string GREETING_ENABLED = 'dws_tpl_greeting_enabled'; /** * The option that holds the number of orders a customer places before the greeting shows. @@ -51,7 +51,7 @@ final class Options { * * @var string */ - public const string GREETING_ORDERS = Plugin::PREFIX . '_greeting_orders'; + public const string GREETING_ORDERS = 'dws_tpl_greeting_orders'; /** * The value each option reads as while nothing valid is stored. diff --git a/src/Plugin.php b/src/Plugin.php index 43c1056..add8111 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -74,7 +74,7 @@ final class Plugin { 'option_prefixes' => array( self::PREFIX . '_', '_transient_' . self::PREFIX . '_', '_transient_timeout_' . self::PREFIX . '_' ), 'user_meta_site_prefixes' => array( self::PREFIX . '_' ), 'user_meta_global_prefixes' => array( self::PREFIX . '_' ), - 'network_options' => array( self::PREFIX . '_network_notices' ), + 'network_options' => array( 'dws_tpl_network_notices' ), 'cron_hooks' => array( 'dws_plugin_template/migration/run' ), 'as_groups' => array( self::PREFIX ), );