diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index e3de119..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 ) ]] || @@ -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#*/}" @@ -50,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" @@ -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/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index b1d81a1..318deb0 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 of up to 28 lowercase letters and digits, such as "dws_ao"' required: true type: string wp-env-port: @@ -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..6453d04 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 @@ -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 @@ -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/AGENTS.md b/AGENTS.md index 7c6d01c..5b5af73 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,20 +5,19 @@ 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 +├── 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 @@ -52,13 +51,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. +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 `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. @@ -70,7 +69,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..fbeed01 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: @@ -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. diff --git a/README.scaffold.md b/README.scaffold.md index 24d0536..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`, 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, 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. | @@ -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/Safeguards.php b/Safeguards.php new file mode 100644 index 0000000..d9a9e75 --- /dev/null +++ b/Safeguards.php @@ -0,0 +1,41 @@ +=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..48afdb0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,39 +4,39 @@ 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_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. ## 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`), 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. ## 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 900a09f..eca82ae 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) @@ -11,112 +11,128 @@ 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) ## 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 `Safeguards.php` at the plugin's root, which the entry file requires by path before the floor gate and the autoloader, so it calls only WordPress, what its class defines, and WooCommerce where the plugin requires it. Each safeguard is a static closure, which no code can remove by name, so 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: +Replace `Safeguards.php` with the class below, its 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: ```php 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' ); } ``` -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 - */ -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(); } /** @@ -349,18 +363,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( ... ), ), ), ), @@ -369,14 +383,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(); } /** @@ -424,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' ), ), ), @@ -437,17 +507,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, 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( '_' . 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(); } /** @@ -512,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' ), ), ), @@ -523,7 +610,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 @@ -585,14 +672,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', @@ -651,45 +738,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 = 'dws_tpl_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 = 'dws_tpl_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. @@ -698,18 +787,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 = '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: @@ -744,7 +833,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 @@ -794,12 +883,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(); } @@ -842,35 +931,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 @@ -905,6 +985,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: @@ -913,7 +1019,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 +1031,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. @@ -936,7 +1042,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 @@ -1027,14 +1133,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`: @@ -1061,9 +1178,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 ); } // ... @@ -1078,16 +1196,16 @@ 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; } ``` -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", @@ -1099,78 +1217,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 ) ); ``` @@ -1187,22 +1306,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/dws-plugin-template.php b/dws-plugin-template.php index ea9beae..a64bacf 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -1,6 +1,6 @@ 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..9f4b259 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. * + * Only the function is contract: the root's members stay internal unless tagged @api. + * + * @api + * * @since 1.0.0 * @version 1.0.0 * @@ -46,55 +66,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/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/includes/footprint.php b/includes/footprint.php deleted file mode 100644 index 08be5d8..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_TPL_OPTION_REVISION . '/migration/run' ), - 'as_groups' => array( Plugin::PREFIX ), - ); -} 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 @@ + - */ -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 79b6dcd..0000000 --- a/includes/options.php +++ /dev/null @@ -1,182 +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/includes/safeguards.php b/includes/safeguards.php deleted file mode 100644 index a957332..0000000 --- a/includes/safeguards.php +++ /dev/null @@ -1,13 +0,0 @@ -=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/phpstan.dist.neon b/phpstan.dist.neon index 762f33d..6d5349b 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -14,11 +14,9 @@ parameters: - models - src # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. - excludePaths: - - includes/safeguards.php scanFiles: - dws-plugin-template.php - - includes/safeguards.php + - Safeguards.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. diff --git a/phpstan.safeguards.neon b/phpstan.safeguards.neon index 77db12a..f5eeeea 100644 --- a/phpstan.safeguards.neon +++ b/phpstan.safeguards.neon @@ -8,7 +8,7 @@ includes: parameters: paths: - dws-plugin-template.php - - includes/safeguards.php + - Safeguards.php # A safeguard calls WooCommerce where the plugin requires it. scanFiles: - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php @@ -18,9 +18,3 @@ parameters: # The entry loads the scoped bootstrap package by path, before any autoloader. scanDirectories: - vendor-prefixed/ahegyes/wp-framework-bootstrap - ignoreErrors: - # The entry's build-error handler runs only once the floor gate has passed. - - - identifier: WPCompat.functionNotAvailable - message: '#^wp_get_environment_type\(\) #' - path: dws-plugin-template.php 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 685f6d0..95dfa87 100644 --- a/src/Greeting/AccountDashboardGreeting.php +++ b/src/Greeting/AccountDashboardGreeting.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\PluginTemplate\Greeting; +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; @@ -14,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 /** @@ -30,6 +48,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 +77,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 +90,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' ) ); } /** @@ -75,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/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..07e6c9f --- /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 3befb4b..add8111 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -3,8 +3,10 @@ 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; 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 +27,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 +37,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 @@ -60,6 +62,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( 'dws_tpl_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. * @@ -110,6 +129,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 +150,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 @@ -152,7 +182,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( @@ -182,14 +212,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, 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; } @@ -200,17 +230,20 @@ 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 ), + Assets::class => static fn (): Assets => new Assets( $identity ), ); } 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/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 ) 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/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..bb26a98 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,7 +3,10 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; 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; @@ -16,7 +19,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 +32,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' ) ), ); } @@ -66,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' ) ) ); @@ -76,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' ); } @@ -96,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' ); @@ -109,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' ); } @@ -121,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' ); } @@ -130,8 +134,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 +146,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 +155,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 +168,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 +176,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 ); @@ -199,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 ) ); + } } diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 0bbabec..3893288 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( @@ -27,19 +28,20 @@ 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 ); + 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] 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 11742c5..ba69e02 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -2,6 +2,9 @@ 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; use PHPUnit\Framework\Attributes\After; @@ -13,11 +16,13 @@ final class MigrationsTest 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 string REVISION = Plugin::PREFIX . '_revision'; + + 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( DWS_TPL_OPTION_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. @@ -27,40 +32,40 @@ 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( '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( 1, MigrationRunner::get_revision( self::REVISION ) ); + 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 cee4d34..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; @@ -23,12 +24,17 @@ 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, '/' ) ); + // 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'] ); - 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 7a22840..56b19b0 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -2,10 +2,12 @@ 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; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use PHPUnit\Framework\TestCase; @@ -13,119 +15,71 @@ 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, "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( 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/SafeguardRecipeTest.php b/tests/Integration/SafeguardRecipeTest.php index a05893c..4538295 100644 --- a/tests/Integration/SafeguardRecipeTest.php +++ b/tests/Integration/SafeguardRecipeTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\RecipeSafeguards; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversNothing; @@ -14,20 +15,28 @@ final class SafeguardRecipeTest extends TestCase { #[BeforeClass] public static function load_the_recipe(): void { $recipes = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/docs/recipes.md' ); - foreach ( array( 'function dws_tpl_is_relaxed(', 'final class SafeguardsTest ' ) as $marker ) { + foreach ( array( 'final class Safeguards ', 'final class SafeguardsTest ' ) as $marker ) { \preg_match( '/```php\n(<\?php declare\( strict_types=1 \);\n(?:(?!```).)*?' . \preg_quote( $marker, '/' ) . '(?:(?!```).)*)```/s', $recipes, $block ); self::assertArrayHasKey( 1, $block, "docs/recipes.md holds no PHP file defining $marker." ); $file = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-safeguard-recipe-' ); - \file_put_contents( $file, $block[1] ); + \file_put_contents( $file, \str_replace( 'final class Safeguards ', 'final class RecipeSafeguards ', $block[1] ) ); // The plugin's own Safeguards class is loaded already. require_once $file; \unlink( $file ); } + + RecipeSafeguards::register(); } #[AfterClass] public static function remove_the_recipes_safeguard(): void { - remove_filter( 'map_meta_cap', 'dws_tpl_map_export_capability' ); + foreach ( $GLOBALS['wp_filter']['map_meta_cap']->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. 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 5288540..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; @@ -15,6 +16,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. @@ -41,25 +44,25 @@ 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 ); 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 ) ) ); 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" ) ); 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( @@ -73,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 { @@ -97,8 +100,8 @@ 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' ); - $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . DWS_TPL_OPTION_REVISION . "'"; + 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 ) ); $wpdb->suppress_errors(); @@ -107,12 +110,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::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . self::REVISION, $died->getMessage() ); + self::assertSame( 'yes', get_option( Options::DELETE_DATA ) ); } finally { remove_all_filters( 'query' ); - delete_option( DWS_TPL_OPTION_DELETE_DATA ); - delete_option( DWS_TPL_OPTION_REVISION ); + delete_option( Options::DELETE_DATA ); + delete_option( self::REVISION ); dws_tpl_plugin()->migrations?->run(); } } 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 diff --git a/tests/Support/RunsRequestsTrait.php b/tests/Support/RunsRequestsTrait.php index 19cf52e..875ae2f 100644 --- a/tests/Support/RunsRequestsTrait.php +++ b/tests/Support/RunsRequestsTrait.php @@ -4,13 +4,25 @@ trait RunsRequestsTrait { protected static function request( string $php, string ...$flags ): array { - $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \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 ) || " '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' ); diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index b4175a0..2a84712 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -2,7 +2,9 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Unit; +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; @@ -16,25 +18,30 @@ 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 ) ); \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 { $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 . '_', ...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 +53,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_TPL_OPTION_REVISION . '/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." ); } } 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 ); + } +} 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 a537cdb..3e4cd49 100644 --- a/uninstall.php +++ b/uninstall.php @@ -8,7 +8,10 @@ * @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; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Uninstall\MetaTypes; @@ -27,13 +30,14 @@ } 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.' ); } + + $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,10 +50,10 @@ try { Uninstaller::run( - $dws_tpl_file, + $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, ) );