diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f77ccff..7d82820 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a defect in the plugin template (the unmodified template, not a forked plugin). +description: Report a defect in the plugin. labels: [bug, triage] body: - type: dropdown @@ -7,21 +7,20 @@ body: attributes: label: Affected area options: - - Plugin runtime (composition root, components, framework wiring) - - Composer scoping pipeline (scoper.inc.php, post-autoload-dump) - - Changelog tooling (composer changelog:* scripts) - - CI workflows (quality.yml, tests.yml) - - wp-env / integration test infrastructure - - Documentation / readme - - Other / unsure + - Runtime (boot, components, framework wiring) + - Settings + - Blocks and scripts + - CI workflows (.github/workflows/) and release + - Documentation + - Other or unsure validations: required: true - type: input - id: commit + id: version attributes: - label: Plugin template commit / SHA - placeholder: 'trunk@' + label: Plugin version or commit + placeholder: 'x.y.z or trunk@' validations: required: true @@ -29,7 +28,7 @@ body: id: php-version attributes: label: PHP version - placeholder: '8.5.0' + placeholder: 'x.y.z' validations: required: true @@ -37,16 +36,24 @@ body: id: wp-version attributes: label: WordPress version - placeholder: '7.0' + placeholder: 'x.y.z' + validations: + required: true + + - type: input + id: wc-version + attributes: + label: WooCommerce version, if active + placeholder: 'x.y.z' - type: textarea id: reproduction attributes: label: Steps to reproduce placeholder: | - 1. Use this template via "Use this template" or git clone - 2. Run `composer packages-install` / `npm install` / `npm run wp-env:start` - 3. Observe Z + 1. Activate the plugin. + 2. Go to … + 3. See … validations: required: true @@ -67,5 +74,5 @@ body: - type: textarea id: logs attributes: - label: Relevant logs / stack traces / CI run URL + label: Relevant logs, stack traces or a CI run URL render: text diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7f6f04b..369feae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Propose a new capability for the plugin template scaffold. +description: Propose a new capability for the plugin. labels: [enhancement, triage] body: - type: dropdown @@ -7,13 +7,12 @@ body: attributes: label: Target area options: - - Plugin runtime scaffold (composition root, components, framework wiring) - - Composer scoping pipeline - - Changelog tooling - - CI workflows - - wp-env / integration test infrastructure - - Documentation / placeholder convention - - New area / unsure + - Runtime (boot, components, framework wiring) + - Settings + - Blocks and scripts + - CI workflows (.github/workflows/) and release + - Documentation + - New area or unsure validations: required: true @@ -21,7 +20,7 @@ body: id: problem attributes: label: Problem - description: What is the template-user-facing problem this would solve? + description: What problem would this solve, and for whom? validations: required: true @@ -29,7 +28,7 @@ body: id: proposal attributes: label: Proposed solution - description: How would you imagine this looking? Code or YAML samples welcome. + description: How would it work? Code samples are welcome. validations: required: true @@ -37,15 +36,4 @@ body: id: alternatives attributes: label: Alternatives considered - description: What other approaches did you weigh? Why this one? - - - type: dropdown - id: breaking - attributes: - label: Breaking change for existing forks? - options: - - 'No (additive — existing forks can ignore the new feature)' - - 'Yes (existing forks need to merge / adapt)' - - 'Unsure' - validations: - required: true + description: What other approaches did you weigh, and why this one? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e842b93..bc2a1d0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,24 +1,23 @@ # Summary - + ## Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor -- [ ] Build / CI / tooling +- [ ] Build, CI or tooling - [ ] Documentation - [ ] Dependency update ## Checklist - [ ] Changelog fragment added (`composer changelog:add`) -- [ ] Tests added/updated +- [ ] Tests added or updated - [ ] `composer quality-check` passes locally - [ ] Plugin header updated if runtime requirements changed -## Breaking changes for forks - - +## Breaking changes + diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index c1750d6..b1d81a1 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -24,7 +24,7 @@ on: required: true type: string wp-env-port: - description: 'Development wp-env port, such as "8821"; the tests and multisite environments take the next two' + description: 'Development wp-env port, such as "8831"; the tests and multisite environments take the next two' required: true type: string diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 39b465c..81af646 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -89,7 +89,7 @@ jobs: 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 - PORT: '8821' + PORT: '8831' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh - name: Install and scope the filled-in plugin diff --git a/AGENTS.md b/AGENTS.md index 91337d1..7c6d01c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,16 +52,20 @@ 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` or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. +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()`. 2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. -7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, 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 WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. +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. 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. +## Contract + +The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. + ## Style - Every PHP file opens with `` on that commit starts the release. +The first release dates the `1.0.0 - unreleased` block the plugin starts with, adding the fragments to it, since a plain write would put a new version above it: + +```sh +composer changelog:write -- --amend +``` + +The release commit also sets the entry file's `Version:` header to that version, and `readme.txt`'s `Stable tag:` where the plugin has one, because the release refuses a tag that either differs from. It sets each `blocks/src/*/block.json`'s `version` too, which WordPress puts in the block's stylesheet URL and the Unit suite holds to the header. + +Land the release commit on trunk and wait for its Quality and Tests runs to pass, since the release publishes only a commit whose runs passed. To rehearse, run the Release workflow on trunk from the Actions tab, which checks those runs, builds and tests the zip and publishes nothing. Then sign a tag on the commit and push it, which starts the release: + +```sh +git tag -s v -m v +git push origin v +``` diff --git a/README.md b/README.md index 949a2d7..a135e1d 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,20 @@ Create the plugin's repository from this template with GitHub's **Use this templ | `plugin-slug` | `acme-orders-for-woocommerce` | the text domain 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 | -| `wp-env-port` | `8821` | the development site's port; the tests site takes the next one | +| `wp-env-port` | `8831` | the development site's port; the tests and multisite sites take the next two | The workflow: - renames the entry file and replaces the template's names, description, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes; - makes `README.scaffold.md` the plugin's README, which lists the template's demo and what removing each part takes; -- deletes the disabled example include and any changelog fragments, and refreshes the Composer lock's content hash; +- deletes the disabled example include, the safeguard recipe's test and any changelog fragments, and refreshes the Composer lock's content hash; - fails when any name, description or port of the template is left, and otherwise pushes one commit to the branch it ran on. +Then turn on private vulnerability reporting and Dependabot alerts in the new repository's settings: its `SECURITY.md` sends reports through the first and development-tool advisories to the second, and a repository made from a template copies none of the template's settings. + ## 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 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 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. @@ -41,13 +43,15 @@ Install the dependencies, prefix the framework into `vendor-prefixed/`, build th npm run setup ``` +wp-env publishes the development site on every network interface, with fixed development credentials, so other devices on the local network can reach it. + Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integration suites on the tests site at : ```sh composer quality-check ``` -Install the browser the end-to-end suite runs in, once per machine, then run the suite: +Install the browser the end-to-end suite runs in, initially and after a Playwright update that needs a new browser revision, then run the suite: ```sh npx playwright install chromium @@ -66,7 +70,7 @@ Update the dependencies within their constraints, and `@wordpress/*` to npm's la npm run packages-update:all ``` -Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. The template itself publishes no release, even on a tag, and takes no changelog fragments, which the fill-in deletes. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. +Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. The template itself publishes no release, even on a tag, and takes no changelog fragments, which the fill-in deletes; `CHANGELOG.md`'s `1.0.0 - unreleased` block is the plugin's first entry. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the checks before a pull request, the changelog and releases, and `SECURITY.md` how to report a vulnerability. ## License diff --git a/README.scaffold.md b/README.scaffold.md index a0ddf4f..24d0536 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -4,11 +4,11 @@ TODO: one sentence saying why this plugin exists, its philosophy rather than a l ## Requirements -The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. On a site below them, WordPress shows its own notice and none of the plugin's features load. +The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. WordPress shows its own notice below the PHP or WordPress floor or without WooCommerce, and the plugin shows its own below WooCommerce's version floor; either way, none of the plugin's features load. ## Installation -Download `dws-plugin-template.zip` from the repository's latest release, then upload it under Plugins → Add New Plugin → Upload Plugin. Later releases arrive through WordPress's own update check, from the repository the entry file's `Update URI` names. +Download `dws-plugin-template.zip` from the repository's latest release, then upload it under Plugins → Add New Plugin → Upload Plugin. Later releases arrive through WordPress's own update check, from the repository the entry file's `Update URI` names, while that repository is public, since the check calls GitHub's API without authentication. ## Development @@ -20,6 +20,8 @@ Install the dependencies, prefix the framework into `vendor-prefixed/`, build th npm run setup ``` +wp-env publishes the development site on every network interface, with fixed development credentials, so other devices on the local network can reach it. + Rebuild the blocks and assets on every change: ```sh @@ -32,7 +34,7 @@ Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integrat composer quality-check ``` -Install the browser the end-to-end suite runs in, once per machine, then run the suite against the tests site: +Install the browser the end-to-end suite runs in, initially and after a Playwright update that needs a new browser revision, then run the suite against the tests site: ```sh npx playwright install chromium @@ -61,7 +63,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas Whichever parts go, four settings follow what is left, since a plugin removes them in any order: -- The entry's build gate checks `blocks/build/blocks-manifest.php` and `assets/build/editor.asset.php`, and the plugin stays off until both exist, so it checks only files the remaining build writes, and drops its built-file check once nothing is built. +- The entry's build gate checks `blocks/build/blocks-manifest.php` and `assets/build/editor.asset.php`, and the plugin stays off until both exist, so it checks only files the remaining build writes, and drops its built-file check once nothing is built. `PartialDeployTest` hides each file the gate checks in a row of its own, so its rows follow the gate. - `lint:scripts` and `format:scripts` name only folders that still hold a script, since ESLint fails on a path that holds none. - `lint:styles` names only folders that still hold SCSS. Once none does, it keeps one pattern, such as `'assets/src/**/*.scss'`, and takes `--allow-empty-input`, since Stylelint fails when nothing matches, Quality runs it, and without a pattern wp-scripts lints every stylesheet in the project. - `build` stays, even once it runs nothing, since `npm run setup` and the Integration run call it. @@ -69,4 +71,4 @@ 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. -- `SECURITY.md`'s scope and the issue forms in `.github/ISSUE_TEMPLATE/`, whose wording describes this plugin's demo. +- `SECURITY.md`'s scope, whose first out-of-scope line names the plugins generated from the template. diff --git a/SECURITY.md b/SECURITY.md index b28bea2..e76a830 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,10 @@ Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-plugin-template/security/advisories/new), never in a public issue. The first response comes within 7 days. +## Supported versions + +Only the latest release receives security fixes. + ## Scope In scope: the plugin's PHP, JavaScript and CSS, its prefixing configuration (`scoper.inc.php`) and its CI workflows. diff --git a/assets/src/editor.ts b/assets/src/editor.ts index 07ca759..62d57b1 100644 --- a/assets/src/editor.ts +++ b/assets/src/editor.ts @@ -8,9 +8,9 @@ dispatch( commandsStore ).registerCommand( { name: 'dws-plugin-template/insert-example-notice', label: __( 'Insert an example notice', 'dws-plugin-template' ), callback: ( { close } ) => { - dispatch( blockEditorStore ).insertBlocks( - createBlock( 'dws-plugin-template/example-notice' ) - ); + dispatch( blockEditorStore ).insertBlocks( [ + createBlock( 'dws-plugin-template/example-notice' ), + ] ); close(); }, } ); diff --git a/blocks/src/declarations.d.ts b/blocks/src/declarations.d.ts index 38c0da6..9d96335 100644 --- a/blocks/src/declarations.d.ts +++ b/blocks/src/declarations.d.ts @@ -1,26 +1,2 @@ // wp-scripts bundles the styles a block imports, so TypeScript only needs to know the imports exist. declare module '*.scss'; - -// @wordpress/block-editor ships no type declarations, so the hooks the blocks use and the store the editor script -// dispatches to are declared here. -declare module '@wordpress/block-editor' { - import type { BlockInstance } from '@wordpress/blocks'; - import type { ReduxStoreConfig, StoreDescriptor } from '@wordpress/data'; - import type { HTMLAttributes } from 'react'; - - export const store: StoreDescriptor< - ReduxStoreConfig< - unknown, - { - insertBlocks: ( - blocks: BlockInstance | BlockInstance[] - ) => object; - }, - unknown - > - >; - - export function useBlockProps( - props?: HTMLAttributes< HTMLElement > - ): HTMLAttributes< HTMLElement >; -} diff --git a/blocks/src/example-notice/render.php b/blocks/src/example-notice/render.php index 96c92d5..2e4d5df 100644 --- a/blocks/src/example-notice/render.php +++ b/blocks/src/example-notice/render.php @@ -1,6 +1,8 @@ notices->add( new Notice( - Plugin::PREFIX . '-export-' . time(), // A viewer's delivery outlives the notice, so each occasion takes a new id. + 'export-' . wp_generate_uuid4(), /* translators: %d: The number of rows. */ - esc_html( sprintf( _n( 'The export wrote %d row.', 'The export wrote %d rows.', $rows, 'dws-plugin-template' ), $rows ) ), + esc_html( \sprintf( _n( 'The export wrote %d row.', 'The export wrote %d rows.', $rows, 'dws-plugin-template' ), $rows ) ), NoticeType::Success ) ); @@ -211,7 +236,7 @@ Add the option, its field and its default, a reader that guards the field and th * * @var string */ -const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; +const DWS_TPL_OPTION_SETTINGS = 'dws_tpl_settings'; /** * The settings field that holds the limit. @@ -249,7 +274,6 @@ 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 ); } @@ -560,10 +584,11 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), ), array( - 'type' => 'checkbox', - 'id' => Plugin::PREFIX . '_payment_notes', - 'title' => __( 'Payment notes', 'dws-plugin-template' ), - 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), + 'type' => 'checkbox', + 'id' => DWS_TPL_OPTION_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 ] ), ), array( 'type' => 'multiselect', @@ -618,8 +643,7 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti * @return string[] */ public function add_settings_link( array $links ): array { - array_unshift( $links, sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); - + \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); return $links; } @@ -627,9 +651,19 @@ 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`, the multiselect's an empty array, 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 `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: ```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 the payment methods whose orders get the payment note. * @@ -664,7 +698,7 @@ 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`: +Add the repair gate's option to `includes/options.php`, with its default `0` in `DWS_TPL_OPTION_DEFAULTS`: ```php /** @@ -675,10 +709,10 @@ Add the repair gate's option to `includes/options.php`: * * @var string */ -const DWS_TPL_OPTION_RECURRING_ENSURED_AT = Plugin::PREFIX . '_recurring_ensured_at'; +const DWS_TPL_OPTION_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, writing the time first so concurrent requests skip, and again when Action Scheduler asks every day: +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: ```php ensure_schedule(); } @@ -808,8 +842,8 @@ final class Pruning implements ComponentInterface { ``` Then: -- Add `Plugin::PREFIX . '/records/prune'` to `cron_hooks` in `includes/footprint.php`. 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 )`. +- 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. - 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 @@ -836,7 +870,7 @@ function dws_tpl_activate(): void { } ``` -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 `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 script module @@ -876,10 +910,27 @@ The build writes `view.js` with a `view.asset.php` whose `type` is `module`, and 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: 1. Rename the literal at every `do_action()` or `apply_filters()` and in its docblock, which keeps its `@since` and moves its `@version` to the release. -2. Add a changelog fragment with `composer changelog:add` that names the old and the new hook, with a major significance. -3. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. +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: -Ship no deprecated alias: the major release is the migration path. +```php +$restricted = apply_filters_deprecated( 'dws_tpl/export/should_restrict', array( true ), '1.1.0', 'dws_tpl/data_export/should_restrict' ); + +/** + * Filters whether the data export stays locked. + * + * Only an explicit false, once the plugin has booted, unlocks it, so a half-loaded build never relaxes it. + * + * @since 1.0.0 + * @version 1.1.0 + * + * @param bool $restricted Whether the data export stays locked. + */ +$restricted = apply_filters( 'dws_tpl/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. +4. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. +5. In the next major, delete the deprecated call, with a major fragment that names the old hook as removed. ## Distribution through wordpress.org @@ -923,11 +974,18 @@ TODO: what the plugin does, for the directory's listing. ```json "changelog:write": [ - "@php vendor/bin/changelogger write", - "@php vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt" + "@php vendor/bin/changelogger write", + "@php vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt" ] ``` +Composer hands the arguments after `--` to every command in the array, so the first release runs the two apart, and `CONTRIBUTING.md`'s first-release command becomes: + +```sh +vendor/bin/changelogger write --amend +vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt +``` + 4. Lint the file in `.github/workflows/quality.yml`: ```yaml @@ -965,15 +1023,16 @@ composer remove --dev --ignore-platform-req=php+ --no-interaction ahegyes/wp-fra - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php ``` +In `phpstan.safeguards.neon`, delete the whole `scanFiles` key, which lists only those stubs, with its comment. + 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()`, and drop "declares its WooCommerce feature compatibility" from the file's summary: +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: ```php -use DeepWebSolutions\PluginTemplate\Plugin; - -defined( 'ABSPATH' ) || exit; +// ... 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' ); ``` @@ -989,7 +1048,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra public const array COMPONENTS = array( Scheduler::class, AdminNotices::class, - Settings::class, + SettingsPage::class, Blocks::class, ); @@ -1024,7 +1083,6 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } ``` @@ -1036,122 +1094,20 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra "start:assets": "wp-scripts start editor=./assets/src/editor.ts --output-path=assets/build" ``` -7. Replace `src/Settings.php` with an options page under Settings, which stores both fields in one option through the Settings API: +7. Delete `src/Settings.php`, and add the settings page from [A WordPress settings page](#a-wordpress-settings-page), with the consent to delete the plugin's data as a second field of its tab: ```php -register_hooks(); - } - - /** - * {@inheritDoc} - * - * @since 1.0.0 - * @version 1.0.0 - */ - #[\Override] - public function get_options_page( string $page_id ): array { - return array( - 'parent' => 'options-general.php', - 'capability' => 'manage_options', - 'page_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), - 'menu_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), - 'tabs' => array( - array( - 'slug' => 'general', - 'title' => __( 'General', 'dws-plugin-template' ), - 'option' => DWS_TPL_OPTION_SETTINGS, - 'fields' => array( - array( - 'type' => 'number', - 'id' => DWS_TPL_SETTING_LIMIT, - 'title' => __( 'Limit', 'dws-plugin-template' ), - 'default' => (string) DWS_TPL_SETTING_DEFAULTS[ DWS_TPL_SETTING_LIMIT ], - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => 'dws_tpl_validate_limit', - ), array( 'type' => 'checkbox', 'id' => DWS_TPL_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' ), ), - ), - ), - ), - ); - } - - // endregion -} ``` -8. Replace `includes/options.php` with the one settings option, its fields, their defaults, and readers that guard each field. The file stays PHP 7.4-parseable, because Quality's syntax job lints `includes/`: +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: ```php - - */ -const DWS_TPL_SETTING_DEFAULTS = array( - DWS_TPL_SETTING_DELETE_DATA => false, - DWS_TPL_SETTING_LIMIT => 1, -); - /** * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. * @@ -1196,77 +1129,10 @@ const DWS_TPL_SETTING_DEFAULTS = array( * @return bool */ function dws_tpl_get_bool_setting( string $field ): bool { - $default = dws_tpl_get_setting_default( $field ); - - return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); -} - -/** - * 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_get_setting_default( $field ); - - return filter_var( dws_tpl_get_setting( $field, $default ), 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' ) ); - } - - return (int) $limit; -} - -/** - * Returns a settings field's stored value, or the fallback while the settings hold none. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * @param mixed $fallback The value to return while nothing is stored. - * - * @return mixed - */ -function dws_tpl_get_setting( string $field, $fallback ) { - $settings = get_option( DWS_TPL_OPTION_SETTINGS ); - - return is_array( $settings ) && array_key_exists( $field, $settings ) ? $settings[ $field ] : $fallback; -} - -/** - * Returns a settings field's default, or null for a field without one. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return mixed - */ -function dws_tpl_get_setting_default( string $field ) { - return DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; + $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 ); } ``` @@ -1308,24 +1174,26 @@ function dws_tpl_add_settings(): void { ); ``` -11. In `.wp-env.json` and `.wp-env.tests.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin; `.wp-env.json`'s command takes no `--config`: +11. In `.wp-env.json`, `.wp-env.tests.json` and `.wp-env.multisite.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin, network-wide in the multisite one; `.wp-env.json`'s command takes no `--config`: ```json "mappings": { - "wp-content/plugins/dws-plugin-template": "." + "wp-content/plugins/dws-plugin-template": "." }, "lifecycleScripts": { - "afterStart": "node_modules/.bin/wp-env --config .wp-env.tests.json run cli wp plugin activate dws-plugin-template" + "afterStart": "node_modules/.bin/wp-env --config .wp-env.tests.json run cli wp plugin activate dws-plugin-template" } ``` 12. Adapt the tests: -- Delete `tests/EndToEnd/without-woocommerce.spec.ts`. -- `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. +- 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. - `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 Settings() )` 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( 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: ```php // options.php saves a submission through update_option(), whose filter the page hooks for its own option page only. diff --git a/dws-plugin-template.php b/dws-plugin-template.php index b325246..ea9beae 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -1,5 +1,7 @@ , 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; } @@ -65,12 +64,11 @@ function dws_tpl_register_script( string $handle, string $path, array $args = ar * * @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. - * @param string $media Optional. The media the stylesheet applies to. Default 'all'. * * @return bool */ -function dws_tpl_register_style( string $handle, string $path, string $media = 'all' ): bool { - $registered = wp_register_style( $handle, plugin_dir_url( DWS_TPL_FILE ) . $path, array(), dws_tpl_get_asset( $path )['version'], $media ); +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' ); } diff --git a/includes/footprint.php b/includes/footprint.php index 71700f5..08be5d8 100644 --- a/includes/footprint.php +++ b/includes/footprint.php @@ -13,7 +13,7 @@ defined( 'ABSPATH' ) || exit; /** - * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix. + * Returns the plugin's stored data by kind. * * @since 1.0.0 * @version 1.0.0 diff --git a/includes/metadata.php b/includes/metadata.php index 76ec661..366d764 100644 --- a/includes/metadata.php +++ b/includes/metadata.php @@ -1,8 +1,8 @@ 'Plugin Name' ) )['Name']; - return 0 < did_action( 'init' ) ? __( $name, 'dws-plugin-template' ) : $name; // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The header's name reaches the POT through make-pot's header extraction. } /** * Returns the plugin's version from its header. * + * @api + * * @since 1.0.0 * @version 1.0.0 * diff --git a/includes/migrations.php b/includes/migrations.php index 6c96580..31b81cf 100644 --- a/includes/migrations.php +++ b/includes/migrations.php @@ -25,7 +25,9 @@ function dws_tpl_get_migration_steps(): array { } /** - * Stores the defaults of the settings that need no translation, autoloaded, so a site that never saved them reads them without a query; a translated default is never stored, because it would keep the language the site had when the step ran. + * 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 diff --git a/includes/options.php b/includes/options.php index 782d7b8..79b6dcd 100644 --- a/includes/options.php +++ b/includes/options.php @@ -8,8 +8,6 @@ * @package DeepWebSolutions\PluginTemplate */ -use DeepWebSolutions\PluginTemplate\Plugin; - defined( 'ABSPATH' ) || exit; /** @@ -20,7 +18,7 @@ * * @var string */ -const DWS_TPL_OPTION_DELETE_DATA = Plugin::PREFIX . '_delete_data'; +const DWS_TPL_OPTION_DELETE_DATA = 'dws_tpl_delete_data'; /** * The option that holds the greeting. @@ -30,7 +28,7 @@ * * @var string */ -const DWS_TPL_OPTION_GREETING = Plugin::PREFIX . '_greeting'; +const DWS_TPL_OPTION_GREETING = 'dws_tpl_greeting'; /** * The option that holds 'yes' where the site greets returning customers on their account dashboard. @@ -50,7 +48,7 @@ * * @var string */ -const DWS_TPL_OPTION_GREETING_ORDERS = Plugin::PREFIX . '_greeting_orders'; +const DWS_TPL_OPTION_GREETING_ORDERS = 'dws_tpl_greeting_orders'; /** * The option in which the migration runner records the revision the plugin's data has reached. @@ -88,7 +86,6 @@ */ 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 ); } @@ -104,12 +101,11 @@ function dws_tpl_get_bool_option( string $key ): bool { */ 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, only after init. + * 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 @@ -130,7 +126,9 @@ function dws_tpl_get_string_option( string $key ): string { } /** - * Returns an option's translated default, which exists only after init, or an empty string for an option without one. + * 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 diff --git a/includes/safeguards.php b/includes/safeguards.php index b6bbfc7..a957332 100644 --- a/includes/safeguards.php +++ b/includes/safeguards.php @@ -1,6 +1,8 @@ =22.13.0" } }, "node_modules/@wordpress/autop": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-5.0.0.tgz", - "integrity": "sha512-aHfC5Z7TmwvuRT0iV9e6l/N17C3VVcXcOlzouEC0x2/SNEc0dR71b3WPc0Qx4P44TAvpp7YQPJcaaM/Fz7Bfbg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-5.1.0.tgz", + "integrity": "sha512-PhQPcSqJ/tQCkS5hDtHnnnpZfB/cxHXkXrcfmCM8sOMUFF0Xea0MmSMDIT4VyPatdXE8SD2WewSO6IOl1r/JOg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8195,9 +8195,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.0.0.tgz", - "integrity": "sha512-mXxwcIfEuj10SnofavV13/0NshtkrI+d1S/vSWrtgU5ibj+ejcvAXBiqIPBsb3RZDZwipTbRJV+VbyBjaf4IrQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.1.0.tgz", + "integrity": "sha512-D59dkRTnhgAoJekTXlXp2oPbUwmiVOC/kiyu6aEh/IJUwK1G9WEpbsvvgsVIWZTJTxGsdGKy3MdcZPW6nKS/FA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8207,8 +8207,8 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/warning": "^4.1.0", "browserslist": "^4.28.7", "core-js": "^3.31.0", "react": "^19.3.0" @@ -8218,9 +8218,9 @@ } }, "node_modules/@wordpress/base-styles": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-14.0.0.tgz", - "integrity": "sha512-BkxwZfnaazn6qziF1yBcYPyGziFArrcsyKgm09fDd0SdegRsh2ZmlENwAcvfuQ23ENS/m3vuFMMmBUbqg+Efmg==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-15.0.0.tgz", + "integrity": "sha512-9ogLMevcb51DuorQblf7P8AIfN2zoKSyfIgmRmH6WhaB0f8MlzLn2SfF6uKsTgbDKmj5n9X9UakRfO7yQWxLNw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8228,9 +8228,9 @@ } }, "node_modules/@wordpress/blob": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-5.0.0.tgz", - "integrity": "sha512-BB2FpHEOq1my0Qe6BD/K5SAWt5I40vDZaFxH5PeeUyZ1KCEszaidkwYsY02sXxRK8rK/WOHGii2fOcpIqrmajA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-5.1.0.tgz", + "integrity": "sha512-pVClxEJWtPalF9O2IBTx9fqJBzpsGx4pArgCvaKkUCymbUKCjT5/ERgk7ooa5YRwjfpO5tZ6GPjC0PBFSPTQUw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8238,50 +8238,50 @@ } }, "node_modules/@wordpress/block-editor": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-19.0.0.tgz", - "integrity": "sha512-HZqOQC10gR7NOU1cUWRrIKx/6XuZBrlbezw8fLgbMpZD91E0oglSqrkvejhE+Ezw8zpDHvD9L/SacfZv8BP/Hw==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-20.0.0.tgz", + "integrity": "sha512-gYDOcVSUAeEVJAtUqvclNxfmYBZmIWXixRW5n+uR6CICue21TICF0YrxcqnuxFcy7dDhGUdt+XERx4ayFs1RNg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@react-spring/web": "^10.1.2", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/blob": "^5.0.0", - "@wordpress/block-serialization-default-parser": "^6.0.0", - "@wordpress/blocks": "^17.0.0", - "@wordpress/commands": "^2.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/dataviews": "^21.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/global-styles-engine": "^2.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/image-cropper": "^2.0.0", - "@wordpress/interactivity": "^8.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/kebab-case": "^2.0.0", - "@wordpress/keyboard-shortcuts": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/notices": "^6.0.0", - "@wordpress/preferences": "^5.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/style-engine": "^3.0.0", - "@wordpress/token-list": "^4.0.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/upload-media": "^0.43.0", - "@wordpress/url": "^5.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/block-serialization-default-parser": "^6.1.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/commands": "^2.1.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/dataviews": "^22.0.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/global-styles-engine": "^2.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/image-cropper": "^2.1.0", + "@wordpress/interactivity": "^8.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/kebab-case": "^2.1.0", + "@wordpress/keyboard-shortcuts": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/notices": "^6.1.0", + "@wordpress/preferences": "^5.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/style-engine": "^3.1.0", + "@wordpress/token-list": "^4.1.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/upload-media": "^0.44.0", + "@wordpress/url": "^5.1.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", @@ -8291,7 +8291,6 @@ "postcss": "^8.4.38", "postcss-prefix-selector": "^1.16.0", "postcss-urlrebase": "^1.4.0", - "react-easy-crop": "^5.4.2", "remove-accents": "^0.5.0" }, "engines": { @@ -8309,9 +8308,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-6.0.0.tgz", - "integrity": "sha512-C9HgZeToeeNouMGjTnz5WV8z4vNZ+NBrX4af4Npy6F/VrikOpMAYYJ5hi1K07g/o9hzalZMYN+fHXvuQcYK9MQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-6.1.0.tgz", + "integrity": "sha512-3A++v/Sa0SSre1Rj5EicD2b4Caah4f6gOwAdfcpurEdksUtMYXOrQLL8wDxt8fC1GpEeirB6M4nA8pL6xEHC7g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8319,28 +8318,28 @@ } }, "node_modules/@wordpress/blocks": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-17.0.0.tgz", - "integrity": "sha512-6lZpvRhaFvg/GxYa6RXauU67g7ocjR2UloY1i53wFrCPoNgZAIIfn2q94IX5KeE35nSr0u8XdzDjyRwFz0QGYw==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-17.1.0.tgz", + "integrity": "sha512-KgqIe1xTCftJmzouALC0xJlpILPgfUfkkN/VrSg8ugwy/HR5woruWlwlgJ8nVAXAfaP7mpb7r+oxnsc5Jjb9KA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/autop": "^5.0.0", - "@wordpress/blob": "^5.0.0", - "@wordpress/block-serialization-default-parser": "^6.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/shortcode": "^5.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/autop": "^5.1.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/block-serialization-default-parser": "^6.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/shortcode": "^5.1.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "colord": "^2.9.4", "fast-deep-equal": "^3.1.3", @@ -8372,9 +8371,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.0.0.tgz", - "integrity": "sha512-eZ2yaOBprRNppFNN/qubJ7C2nOX6llR+IeQ5jQC1665sFcOkZes+hLwpbKsZNcmL26ge+77vdQ0f/Ur67rgkgw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.1.0.tgz", + "integrity": "sha512-4vPRj28I2wTiGhUoVU9Kirjed+1eurphEjGpf626FlIU0f9sExLMuTWYLjqgU9aVvxkmm1M+CKdrNL0GnSnv9g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8382,22 +8381,22 @@ } }, "node_modules/@wordpress/commands": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-2.0.0.tgz", - "integrity": "sha512-pfkGCvuEOJ6v67++VKdn387pM3AFbAQKVLZJ2lq9Ei75xvd7pkmsnz3iqLA326E88IiXmSY49KSXuU/4bGsCYA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-2.1.0.tgz", + "integrity": "sha512-NBZzSLMNxrfPkyhHLoIbLVgmfBTXn3o4nw5FU+KHf7R7CmnoJsE+thS2SOyEXfEDhNHJbx0fIdeRaqnZMFWg8A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/keyboard-shortcuts": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/preferences": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/keyboard-shortcuts": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/preferences": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", "clsx": "^2.1.1", "cmdk": "^1.0.0" }, @@ -8416,9 +8415,9 @@ } }, "node_modules/@wordpress/components": { - "version": "43.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-43.0.0.tgz", - "integrity": "sha512-8IfVyT0QXTlhcw9id9aSygYISKR+xjyyVWuJuwMGgb/SqHGBFLnBBKBRq/ypamF/eB0vE8Gb+AmdjP6G8P1LBw==", + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-44.0.0.tgz", + "integrity": "sha512-JgRqJm0Tdz5yI0z5i48W3vAinaOSuZvK+GfAcg8yWRF+FATwSgme7xOYSkTmKRKMmK/jbxvW8OIoPyYisZkM5w==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8427,27 +8426,28 @@ "@floating-ui/react-dom": "^2.1.9", "@types/highlight-words-core": "^1.2.1", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/escape-html": "^4.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/primitives": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/style-runtime": "^0.14.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/escape-html": "^4.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/primitives": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/style-runtime": "^0.15.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/warning": "^4.1.0", + "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", "csstype": "^3.2.3", @@ -8480,21 +8480,21 @@ } }, "node_modules/@wordpress/compose": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.0.0.tgz", - "integrity": "sha512-xP7EDOTyJrc5fC48Zh/u9Hvu+eHvKlU+qn5OyDQi1ahmg5GqNq3UAaODnsgeLyggGgWtmJhbzgN0qIJvhn52Sw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.1.0.tgz", + "integrity": "sha512-L3YoJWyIgQs50Y4A9qdptjDWdfWjFr9V3TqZIzR/yehBA86UwlmP5iBhM8epQTylDf/RsTttJBm8f37/wQvStw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/undo-manager": "^2.0.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/undo-manager": "^2.1.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5" }, @@ -8517,19 +8517,19 @@ } }, "node_modules/@wordpress/data": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-11.0.0.tgz", - "integrity": "sha512-z74Y4/cfyw+Kel5uhVYsoZTytFtUw2xRrfFpa7uWIdfrdQxSVgxajd74pRjxGQ6W+g0VWzV6ZBptHDxYf58Nmg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-11.1.0.tgz", + "integrity": "sha512-pZnLtXoMGWpq69X3vtppNeW3z/egAAE2LTicVChjTsViXIVYvAhm3eKEWYac4jkxps4vdE9u8B0KUHTtBDum4g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/compose": "^9.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/redux-routine": "^6.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/redux-routine": "^6.1.0", "deepmerge": "^4.3.1", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.1.0", @@ -8556,9 +8556,9 @@ } }, "node_modules/@wordpress/dataviews": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-21.0.0.tgz", - "integrity": "sha512-/aojI+0aZEpMYkteZ404V2Bhyxu0Kr5zcf7WhEgmIyHEcfBmuoKIxiPjnVQgAC990gmwW522LYBLnepOymdIOg==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-22.0.0.tgz", + "integrity": "sha512-VzL6dVAPyBdciHk8YDIP1dfNSfLep1hOWp8rvpnV4a4RjvSV21QMYR4Fsoje81CAjbdEU7T0s+AOV5BKH7AgPg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8568,22 +8568,22 @@ "@daypicker/react": "^10.0.1", "@floating-ui/react-dom": "^2.1.9", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/kebab-case": "^2.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/kebab-case": "^2.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", @@ -8614,13 +8614,13 @@ } }, "node_modules/@wordpress/date": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-6.0.0.tgz", - "integrity": "sha512-knjw0jMLk2RzLq8wUzLuS0lvNI1UqwpbqxYVuSyXrUb+8GKXaT/uq0ql82Dha0RiagNCfrneowZFo3mJc7EyTw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-6.1.0.tgz", + "integrity": "sha512-BhvzGu86dKbZko9ohBgI7/DBKdMb4X33qQKhM45rUib8yME1DcP8F/Y0BRmi5RxdZfoRICp43VaIyV+wVd2qYA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^5.0.0", + "@wordpress/deprecated": "^5.1.0", "moment": "^2.31.0", "moment-timezone": "^0.5.40" }, @@ -8629,9 +8629,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.0.0.tgz", - "integrity": "sha512-SYzb/CWt8VfGGMwGb7UVjBV8qGBkY6PqybUWFtn581lx/KMJANwswLPTWA/QOhog0NSDynscpQuOPibQ5ULceQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.1.0.tgz", + "integrity": "sha512-9oFBFJJnJLQl6izhyXDQfFpHUrCNNZ3YUC83AtmwcMpi0AjesuEZ5OYX7SdMof6vRSOG+6UXIvs61Z8S5NswjA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8645,35 +8645,35 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.0.0.tgz", - "integrity": "sha512-PZjrzeX15ZNIDvcBzOXnXF/FpKNwZgz23hQ+xpcmRBjZKCyT6YoxC45O3jFIGyZZtBwxoxCYQPBkCjRuKbVcpg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.1.0.tgz", + "integrity": "sha512-y56h8AeOVDxXylvVqZRtDjEPO5Z3Kp2ku6XuCnb0j+LeJfTnPhkPN/G1dh07S87b1BzwvI4AYeJa8ZpH/N1eRw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^5.0.0" + "@wordpress/hooks": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/dom": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.0.0.tgz", - "integrity": "sha512-jKRFuQgNuAbFhiIU/b1xQl00rEphMdBQal6pmiN/pDVfIR1idZFaaI8K+eH/Gqn+yb/q20YIqPLM1+SXR4xy8w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.1.0.tgz", + "integrity": "sha512-KAul2g72Krm3JWKloiKYdlIsk9uFQYY8FpL2dFFyniSy7kUXo8yVG70YnxSeQfOG1mMWXGog8N6h6OkTTvG8nQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^5.0.0" + "@wordpress/deprecated": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/dom-ready": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-5.0.0.tgz", - "integrity": "sha512-a2Y+loEwENAM+kyTnXU7LTe8GgriXWfoxbco4486e2FuNpfELPLjDLAeJVbaFuw8pbtkk6xARlLh32y7yGG7Rg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-5.1.0.tgz", + "integrity": "sha512-m+sdUHY4ywFcxmAS0QNTegzLJv7aefwEflRNfjIF6UwLLatuT7S93tnJ4uWr6avB84wsDzVDzCgFlDkJJzGbCA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8681,9 +8681,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.1.0.tgz", - "integrity": "sha512-ybc60l3g1syfpVJ7L/zOculIYBsfVfwsWOAn/bm4vN4+irRT6jJ/11bbPhclb9yjDyGatn+aHw89OhhHLjz8mQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.2.0.tgz", + "integrity": "sha512-U7SoahoL8V3UUngjeSYHeKNnQb6EvF70hEzl9rV+Qap6exbNCywo11K0AU7IK0JWgAK5uqVCnC9TVEuYmamdrg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8707,13 +8707,13 @@ } }, "node_modules/@wordpress/element": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.0.0.tgz", - "integrity": "sha512-gv3cZ3O2j4y7qUUBo/h1Ni9OxG6ak78+D0YhggMsGyIPavT4CpJtRyMbarhENJ7K1MM9JzpuecdlMID8Cfd51Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.1.0.tgz", + "integrity": "sha512-MfHroJjXMcbPZvs8xzewbRJeW4gLnwzI856PqY4SrBnEStkRwzpSbLJW0HEsHaOK1d+Wnu4Hm60amuLLe6WdsQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/escape-html": "^4.0.0", + "@wordpress/escape-html": "^4.1.0", "change-case": "^4.1.2", "is-plain-object": "^5.1.0" }, @@ -8736,9 +8736,9 @@ } }, "node_modules/@wordpress/env": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.0.0.tgz", - "integrity": "sha512-RoUQV3sl1qF4h/kg+vrec9PCfr2bwXSOmWoLd9NmV8rD/sCBMt7tSBfeTWZEbClMhWHce8tu4hJ+pQeHXNnu2A==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.1.0.tgz", + "integrity": "sha512-wLodozbP19ON/yV+wDmf8yxbq9NOiacKcaKkLM1wF5c9pO516tTHn5kPrMItyI6B6m/9oULPiywlBUjzfqvF7A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8764,9 +8764,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.0.0.tgz", - "integrity": "sha512-fWEvTJvNyPUSAXz3pMETrt1DHKaQ9CJVirhuBqb1xuZmIdRY4AlsjLgayd+lUSTkTU+0gRIPu1TDdU5/6Ue2Nw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.1.0.tgz", + "integrity": "sha512-k9lDVTRi2vbTKLyQJDVxNwthTqAmsNSxTVJurDGAdZfXXvbR5QrASTNXXK/sfofqf2wWDFAgI567rhKiAKiYmw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8774,16 +8774,16 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.0.0.tgz", - "integrity": "sha512-VRFrez67J9HWGR4s149A3ZG1ynY9uEczmuR7SFXzlcUn3itlo5ood42pv1dgTZK3eqJo3l5tdK3hD94W+ZEq8w==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.1.0.tgz", + "integrity": "sha512-u2yK55Bx/ycVn6ZSYim73pxE2gscNVUGF22amHD6sY/vGU9SpQPg0By0WJPmPtXsMBNDhNEYPmDN95MKTxb8bg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@eslint/compat": "^2.0.0", "@vitest/eslint-plugin": "^1.6.27", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/theme": "^3.0.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/theme": "^4.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -8817,17 +8817,17 @@ } }, "node_modules/@wordpress/global-styles-engine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-2.0.0.tgz", - "integrity": "sha512-lhMen98kG/nLv3QPD8HWXPdhuXDbOje7/gYpljv4zKBjAf3tfbRQ11MblKW8OvYUux3Iu9vaZE+DB2LOgNee5g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-2.1.0.tgz", + "integrity": "sha512-7fvMtp5V9SDT8hP/ju9kwdwlobTXNKoSwhNgVystKtfMndaGSLG/8QSsnZ+29lIhEqVwlDuzkScpIHu1PMG2iA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blocks": "^17.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/style-engine": "^3.0.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/style-engine": "^3.1.0", "colord": "^2.9.4", "deepmerge": "^4.3.1", "fast-deep-equal": "^3.1.3", @@ -8853,9 +8853,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz", - "integrity": "sha512-jA2hDMNr2Ky/D14s8K+TQ/ZI125icjZfTPZYcsgEHM6mTtSJY2dhcmp9+tfSrcnY+M17svzplIxXma+bpBzcFg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.1.0.tgz", + "integrity": "sha512-EvHdv0beYOcINcY91jj+brsoLu87AEp2k7gzJC/Xdux3Ng0WLk2VL9A66vpsyZ61J1/Uiivmo3zR/LhC3Y0UzA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8863,9 +8863,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-5.0.0.tgz", - "integrity": "sha512-rj95Op3y+4+QsdOzlguOT8lpAHegJwdMsDx9Eq/9/fL/CGskgugUm4aqqeFQDNd7B+gOzXM7NZMX2ohaYYXYdA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-5.1.0.tgz", + "integrity": "sha512-/zfBha0FxzoZC5LAhzD+EodEmqEE04g988wBhgIL83uaQV+5/pNWdbOlafbtaUpwZlIVQAUJr9mw+GDmuLiGQA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8873,14 +8873,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.0.0.tgz", - "integrity": "sha512-xk7qKLDB08c3LDUtThfmuumq//354bXLGRrPWfC1gmwPMNJPRC7ggwtSFxzH+ImdssxIKk8NaG4H3yF5WJoS5w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.1.0.tgz", + "integrity": "sha512-WCwJZLUlyI5rbdCBdBDxPGgD405JjiQPzyHRcqS2FwZZDb1+dFnMuDBgeewzWKEtsXlOLJnrJQWKggNJVXnOKQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^5.0.0", + "@tannin/sprintf": "^1.3.4", + "@wordpress/hooks": "^5.1.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -8892,14 +8892,14 @@ } }, "node_modules/@wordpress/icons": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-19.0.0.tgz", - "integrity": "sha512-LPx0Tppj5Y/54fuqIRMUqzplP0ZQL1nNvWthUv+ctpylDbGe4qdlWJpLCLZq1BpI1IXS2s3g5NaN2dXFi5H45w==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-20.0.0.tgz", + "integrity": "sha512-dCduwT+fHydn4yKSPCq3L8fsKhOokOkVjX6mIj7x17fuDwYAOCpXR4fZ6KB73V4+UZjP2csbot/WDJBRTzuWBA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^10.0.0", - "@wordpress/primitives": "^5.0.0", + "@wordpress/element": "^10.1.0", + "@wordpress/primitives": "^5.1.0", "change-case": "^4.1.2" }, "engines": { @@ -8921,15 +8921,15 @@ } }, "node_modules/@wordpress/image-cropper": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/image-cropper/-/image-cropper-2.0.0.tgz", - "integrity": "sha512-Ach0WfHZbwM8oomQtgHsTMgG0+wuhlZYD7ZHsygREkPVD9f0Kd+513rtRFfMdQ2NJzdjqJXSD2nEK0hT/gGjOQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/image-cropper/-/image-cropper-2.1.0.tgz", + "integrity": "sha512-XwELLxV8JJBOGCwWLerzDsaN8VhlEpCZHrBlSysD+T/jr6sftVNp6GpmfXuyD8WAd4XhzvfmiqMMS3vuW5ZxbQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/components": "^43.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", "dequal": "^2.0.3", "react-easy-crop": "^5.4.2" }, @@ -8948,9 +8948,9 @@ } }, "node_modules/@wordpress/interactivity": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-8.0.0.tgz", - "integrity": "sha512-VI60fYRVsSErIs/K9xnUNoAXUycgQwMGP7cq7YHvWdKROPE285+lnv9GarRGFR7W5/9i5fk3a8ifV+2o6sMCOg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-8.1.0.tgz", + "integrity": "sha512-BT2xZiWF963y8l1agiTGQXVKcdBip1gOXlH2ZzSmstILBVHu56zgX5Mg8GJ5ZiImkpjYgYRrxJ96EBfPTnv9og==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8963,9 +8963,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.0.0.tgz", - "integrity": "sha512-KMEZTCaXWp5udPNdPc8lEky3AEhKnUej0aKfjV38Ggox2e87SDXALt3DdMhydO//2xlXZXfGtYtBJ0qk9LpnLQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.1.0.tgz", + "integrity": "sha512-3zZw6SnVLPCfxHxHo7nlg6lRzKn7te/h4al2kdZ3GMWUAdtRsiJZKgiJcVCqNlJN7tbOyNXUepbVXyGJhrTE3Q==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8973,9 +8973,9 @@ } }, "node_modules/@wordpress/kebab-case": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/kebab-case/-/kebab-case-2.0.0.tgz", - "integrity": "sha512-JjPgCJ5SRbbiKboK1U+k37NY8Hb42hHzMphczPtYPGGRNiasMV7T4RkbKlvUo+Qcm0hE6G8ct9VnEsfSsEbjoQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/kebab-case/-/kebab-case-2.1.0.tgz", + "integrity": "sha512-3Rsnx3l6uCvqSeZdnvXBLcV2boYDeKHscrTsoEs/PiJAMj+Kaa/AtFGqIUs2sD7ys5taD4Y5QnM3Nf0W3KfKXA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8986,15 +8986,15 @@ } }, "node_modules/@wordpress/keyboard-shortcuts": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-6.0.0.tgz", - "integrity": "sha512-6OkdL+ZFJ+0llQtiDkP5OifzKvX7NMJpwsbBssm9vfNa93okyLOWY3j8HzUyKQLnuSrO8z+SwBzSJjImUHorTg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-6.1.0.tgz", + "integrity": "sha512-8sLzTrGbIhLACP0IXnD2NqwRC5i1Syi1g7LlpR5+nd3r4akLYvFRv1M7AfVN4Dkfgz8U0gsdaVu+hsLVhbvd+Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/keycodes": "^5.0.0" + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/keycodes": "^5.1.0" }, "engines": { "node": ">=22.13.0" @@ -9015,13 +9015,13 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.0.0.tgz", - "integrity": "sha512-15T931kJjj90KlDcsXKaGucK+iyqY9CftR7tXO60m8T8gDC2GVuUWMIX3Sq9+4fsahABuqoUd+QTiW4xSDlpCg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.1.0.tgz", + "integrity": "sha512-bqelkvytmYitRbE/3LvMw2xHHFVgq+WYHfv6CiQ+O2HwOES45fuPYhCw6r05yZO5zEg+nh0dB0X4bfeR8kwAsw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^7.0.0" + "@wordpress/i18n": "^7.1.0" }, "engines": { "node": ">=22.13.0" @@ -9036,21 +9036,21 @@ } }, "node_modules/@wordpress/notices": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-6.0.0.tgz", - "integrity": "sha512-IDiPfOgUvswr1aXVm3SwOziTtZY+R8t0CfO0jHzK1M50ddzRRTReZPM1bPftU23PAW5k1UqxPc/aYRwLK4Xopg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-6.1.0.tgz", + "integrity": "sha512-W+2+GJg2w6RoocMrXjoK7JJGOxISlOcRUd0Dxwz/xAvMGgVMy1Sh/1Aq7OMGWgFeemWBeClJ1H0icrPE+YlNGw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/ui": "^0.25.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/ui": "^0.26.0", "clsx": "^2.1.1" }, "engines": { @@ -9072,9 +9072,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.0.0.tgz", - "integrity": "sha512-h0VN1RW3OqsreiFrJ13FD/jFX9b+VHerfWNED/OUUSi17pmVgik47niTfth1/xpotL+kz2RuBopb9oV+HpKcQQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.1.0.tgz", + "integrity": "sha512-72fpK1zMW3X9xSarhweHqoBOiIYJD4FWGTkjrTYxUWF3DvR0wmKNqbL2mpzL7aD7tCBVEPN8tKhhDAqdnnS26g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9085,13 +9085,13 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.0.0.tgz", - "integrity": "sha512-Z8PLYiY32e0k7xQz0Fc7K30PcOIFvaoc9JduPMQMCfmukJKFMaRYxqyJZNdva7fPu18vUl9dFpB6ZP/+C5aZYQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.1.0.tgz", + "integrity": "sha512-oOGrqTX0v+M44V/nliAS0OUr1Mmtn6TfMBO9zll0DP7/+CdPyEdRcNoTjicXWhd/DnYfAsetcBBtSBaBSLJTMg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/browserslist-config": "^7.0.0", + "@wordpress/browserslist-config": "^7.1.0", "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, @@ -9103,22 +9103,22 @@ } }, "node_modules/@wordpress/preferences": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-5.0.0.tgz", - "integrity": "sha512-rQaSz2di9k3tzm9BkO26ikydgevBDf+QU9ET97rFjlSaoDqQ1YqOLcFH61o8vtthr3NjQFBq/XAz38VMCVVsXw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-5.1.0.tgz", + "integrity": "sha512-YSsyvXgGlhuOIqFTBd65C3uSesCD2FvD522iWbfSjZyma31CAYHts0iL7pEjRkvGoT+grgjQ7IqShpgJxVN6nQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/private-apis": "^3.0.0" + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/private-apis": "^3.1.0" }, "engines": { "node": ">=22.13.0" @@ -9135,9 +9135,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.0.0.tgz", - "integrity": "sha512-uQ3rtrqt8yvUHFN9Xcxuw7EfSw4ArMkwFxtz2RHZM2Sis9jzB7xk0FcK/iWE55q0WWSjmFGWoNVpOEQoSE7ocQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.1.0.tgz", + "integrity": "sha512-Ep67YhwkEVAO2i+7lbzNqQrvUlSFY1oecth7WehXvp2jq0X2X/aDNEMOPxbY0sf6KKmYaWrRiu5ECOqB2QgJ7w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9148,13 +9148,13 @@ } }, "node_modules/@wordpress/primitives": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-5.0.0.tgz", - "integrity": "sha512-XrRJIbNXai/jlL9FLEANUzaAigTdN8XgWzAHTfAjDskvy3X6CjUbMyjXLWE5OhyCKhq+JAd3mWb9zq4sgl+V2w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-5.1.0.tgz", + "integrity": "sha512-SAZJ2acNDZSGBe2UnJYfpt8pswkiNyZ1hPBbd+yCTmZezPequaCtnRImXl6rtdxlIbfG/1y2nGsn190I6xcWow==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^10.0.0", + "@wordpress/element": "^10.1.0", "clsx": "^2.1.1" }, "engines": { @@ -9176,9 +9176,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.0.0.tgz", - "integrity": "sha512-YXEwn7xtr4bUts2IOPREjlWYa3n/ba7ndLCqFN3ofeRvvr2ZVCI1WP20v7a+CQZi+n9FS7fy+lYqx3wnTYtGzg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.1.0.tgz", + "integrity": "sha512-Xt9O99LLh3YT5LVXsxsGrCJDOuGCv61qK6dWZLitCIg/BxCPc/ihfV98lm4Fupye57439vqPIHKpjV3vJEmUBg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9189,9 +9189,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.0.0.tgz", - "integrity": "sha512-IDXGi2VWfjUFwBTDtWj7/FMY240JpqraxFEIaXIeD3KFumX8lQuAYvE9gU8GcNBt/NlXZCDEmNioxCbAZN+jiA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.1.0.tgz", + "integrity": "sha512-Zj9LAP8+Z+2tihAEPNniS6+t0oYSEEbkQ4uHtvzvZpaD+UM9EAmThpMPKR4YP9l4XMb/Y4W/fiMSNm1mDnPY1w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9199,9 +9199,9 @@ } }, "node_modules/@wordpress/react-inert-value": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/react-inert-value/-/react-inert-value-2.0.0.tgz", - "integrity": "sha512-inwoViBZ2K3ubTvfjkvpzwpXovZVF05CMWZ08scQt+ttYHD1Sf3pL3gn11iAxcR1Wf/0TzZoPeLc+PhnAUzuSg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/react-inert-value/-/react-inert-value-2.1.0.tgz", + "integrity": "sha512-JBx38pTvCCbrY28/UKExlxkr6fQtFNoX+Apk2+bDXg9rewDyr5sgWBJQqbKXLS5XsM7Xfgs9y/knmvRR6zmzcg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9218,9 +9218,9 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-6.0.0.tgz", - "integrity": "sha512-mJm9DeHXFIPeaCA6E+VYupWXBW7g1QuKqEgBclm3HCDN+Th6OO+HMLfa2eS+dAPyoTu2NhSF0QlY1ArijOzuiw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-6.1.0.tgz", + "integrity": "sha512-Oayhin+I/u8e5lH99ey7+Vj+8ww6rkLkYGhFwFB3gdl+KBN8a9MTua3WwLZDDjN4Eail+NZCJWy4/bkSOzIKiA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9236,22 +9236,22 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-8.0.0.tgz", - "integrity": "sha512-2Jg50SJwE82+qO8tnQbIhSqCIb9I+rW6bi0hRVcTMOtRxC8wBisw5n0zngLfGRSHtAA9irnEzrOfPk2m6LtyuQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-8.1.0.tgz", + "integrity": "sha512-UpIpVlRN1w4XC1tirXebMvJ/stJezL6Z7wWq+luS0+1FwYz1Kelq92ZXEWykpKK7odlJYYvDiqWBhWMrZjpNZw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/escape-html": "^4.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/escape-html": "^4.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", "colord": "^2.9.4" }, "engines": { @@ -9273,24 +9273,24 @@ } }, "node_modules/@wordpress/scripts": { - "version": "37.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.0.0.tgz", - "integrity": "sha512-/a01kTH8lqAPzO6AhZRBiFpWcDorZs32U07YdkNhV7UOQAhWrBAWBOXPsLBcCh7NRt603C/hYOea8YcIqPpbZA==", + "version": "37.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.1.0.tgz", + "integrity": "sha512-75ZJTN+uAp9ESxS43hbQjYJRjHFUb0gTF1SQ53iDsX5MxjDjQRzdnNpNMw5peYz5Sv4WE7/l3Hfg3WkVm5U3sg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.3", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^10.0.0", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/dependency-extraction-webpack-plugin": "^7.0.0", - "@wordpress/e2e-test-utils-playwright": "^3.1.0", - "@wordpress/eslint-plugin": "^28.0.0", - "@wordpress/npm-package-json-lint-config": "^6.0.0", - "@wordpress/postcss-plugins-preset": "^6.0.0", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/stylelint-config": "^27.0.0", + "@wordpress/babel-preset-default": "^10.1.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/dependency-extraction-webpack-plugin": "^7.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/eslint-plugin": "^28.1.0", + "@wordpress/npm-package-json-lint-config": "^6.1.0", + "@wordpress/postcss-plugins-preset": "^6.1.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/stylelint-config": "^27.1.0", "adm-zip": "^0.6.1", "babel-loader": "^9.2.1", "browserslist": "^4.28.7", @@ -9570,9 +9570,9 @@ "license": "MIT" }, "node_modules/@wordpress/shortcode": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-5.0.0.tgz", - "integrity": "sha512-anwstb/SfweKgw8BNEouvzwCII9AAY7I/asPZJf084rEtG/5V2II9ls0PdfamYuDedNvFdToCV3uiK9NFD06jw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-5.1.0.tgz", + "integrity": "sha512-o5Rjf21cXSI5cOX3MhjkdMyr1nUtpIp6X/3IdSK0PoB4SOfr4pTu6O7QqFG3/UuAgkuAX+STtqKla2zJ80aYow==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9583,9 +9583,9 @@ } }, "node_modules/@wordpress/style-engine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-3.0.0.tgz", - "integrity": "sha512-obU8yuXphfCZVdcv7bVf/dn6AlzBqYfglyyc/ymqEyjmh4cTY0pF/Do1sraT/XP5n+NZrFGV5D9wcJILTNC3Lg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-3.1.0.tgz", + "integrity": "sha512-sqUKzTn7ZEoSAaTCusMUAU2cRhJihPtKydSkQI/luraOhDxq3m4ZRBn38xTo14AU+ji0S5HRfk9XXWOL3AkRBA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9604,9 +9604,9 @@ } }, "node_modules/@wordpress/style-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.14.0.tgz", - "integrity": "sha512-cWk3G7Pe6Wo9uMpaPAbrhsPJAmkShPn3LLc8vkte1ZQ7ls1M+e6m78KQxd0HYy4ty2nbGGlhnHE7ISxQzGhrsQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.15.0.tgz", + "integrity": "sha512-Rc8D0DK17I7OfG1KbMVw3E8SC+84D4LRMnHq0eHbNwilBTogGiieo0MWx1j6Bzkf82Q5n5JCPct+zm0W0Gk84A==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9614,14 +9614,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.0.0.tgz", - "integrity": "sha512-NvLTijt+fZR2/0T3EwH5cswLdpuo/On1B+BQlkkuQ3HsNrwWLTEvwboaOqzJUwFs/owB9+AO11Ocaayexesalw==", + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.1.0.tgz", + "integrity": "sha512-AAV9GMDyRCnHKfmntb8C40u1zSDIkV1HotHMD3CK0Om+VX1aYVjkdRJikSzvcYEcaS6zK8KaKpDFlSvvNbdcXQ==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^5.2.1", - "@wordpress/theme": "^3.0.0", + "@wordpress/theme-build-tools": "^1.0.0", "stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended-scss": "^17.0.1" }, @@ -9629,14 +9629,55 @@ "node": ">=22.13.0" }, "peerDependencies": { + "@wordpress/theme": "^3.0.0 || ^4.0.0", "stylelint": "^17.14.1", "stylelint-scss": "^7.2.0" + }, + "peerDependenciesMeta": { + "@wordpress/theme": { + "optional": true + } } }, "node_modules/@wordpress/theme": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-3.0.0.tgz", - "integrity": "sha512-m25ORtLD77WL40vG3Vt/0WKzg/liuiM2PGMNaDv4SDO1hZ7H0t+Lp8rfTlsYnBdI+iO8+juICyK4OiVRde1I0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-4.0.0.tgz", + "integrity": "sha512-VKlIu/ulNrJjeNvWw50HD3x2wKq/ntGQnir5ep0r5DOjpdYYEV0/oGXQSQBARcd4H6TncKX5NzaGL0aBYQq7sg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^9.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/style-runtime": "^0.15.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/theme-build-tools": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme-build-tools/-/theme-build-tools-1.0.0.tgz", + "integrity": "sha512-4oy+LYzAokRTentySXjyza7RCMUJG8Ul6mLNTOC+hcwCdwo7yzR3SMhyJfe4uFPON1CNk+s2claCmiIHZnRSPQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9644,30 +9685,21 @@ "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "@jridgewell/trace-mapping": "^0.3.31", - "@wordpress/compose": "^9.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/style-runtime": "^0.14.0", - "colorjs.io": "^0.7.1", - "magic-string": "^0.30.21", - "memize": "^2.1.1" + "magic-string": "^0.30.21" }, "engines": { "node": ">=22.13.0" }, "peerDependencies": { - "@types/react": "^18 || ^19", + "@wordpress/theme": "^3.0.0 || ^4.0.0", "esbuild": ">=0.27.2 <1.0.0", "lightningcss": ">=1.33.0 <2.0.0", "postcss": "^8.0.0", - "react": "^18 || ^19", - "react-dom": "^18 || ^19", "stylelint": "^16 || ^17", "vite": "^7 || ^8" }, "peerDependenciesMeta": { - "@types/react": { + "@wordpress/theme": { "optional": true }, "esbuild": { @@ -9679,12 +9711,6 @@ "postcss": { "optional": true }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, "stylelint": { "optional": true }, @@ -9694,9 +9720,9 @@ } }, "node_modules/@wordpress/token-list": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-4.0.0.tgz", - "integrity": "sha512-W8WY1B4POIXLQcWM/N3Zz9TGMMqtfJvS2VcIql889eEFKk1trx0ynsbzVFO/UblGiSjAdxpANES9yHNKa43VWw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-4.1.0.tgz", + "integrity": "sha512-r8DCgJDIyGFVufgSedJ9vAhZQWJCNo0Evy/OTcFwVwRxYxi4X753bOZIcmb+VnIc7+rxxWes3K8VCmmUI0wKew==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9704,26 +9730,26 @@ } }, "node_modules/@wordpress/ui": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.25.0.tgz", - "integrity": "sha512-G/aUYgQpL18q3DfyV/1ebB8tEjXBubWPPXitcs4IvXYbAAFNBLsoUUGv5K3sfqJuxhi51lbyKT0Eap6Wj0kkCQ==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.26.0.tgz", + "integrity": "sha512-ET2RmxE/nfwRpq1fip1+YATtuLOrOyJJq6hRDDHTyC+8iToM/IbVg8dj/ES6zwlW8/UAEStvSj6IkmQuDvc3mA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@base-ui/react": "^1.9.0", "@daypicker/react": "^10.0.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/primitives": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/style-runtime": "^0.14.0", - "@wordpress/theme": "^3.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/primitives": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/style-runtime": "^0.15.0", + "@wordpress/theme": "^4.0.0", + "@wordpress/warning": "^4.1.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", "tabbable": "^6.4.0" @@ -9743,33 +9769,33 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.0.0.tgz", - "integrity": "sha512-OQfGPUBz79/NKkkBvF5L/k1DmxuCQGYvczA/BOef+ECCCnjpIXF6HS1txpULozI5owejQDDjhZp25bq25jF5Vg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.1.0.tgz", + "integrity": "sha512-n4c6/9V51sNzche2wJReKVUm7c/9ChZsg52939FJuITcLBp5MnR+oEeSe2RQPP4Mq1x3EQAFuM2HibaEnliqvQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^6.0.0" + "@wordpress/is-shallow-equal": "^6.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/upload-media": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.43.0.tgz", - "integrity": "sha512-Cgi0XNoWurFFbCJPljPaii2cy7hHwMml3Dbf9zhlUsyihT3H9LfkSR/Oox+NHFPOqAPv1q2YPWCGuZe3mXDZEQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.44.0.tgz", + "integrity": "sha512-/ML4kvdhnVxNPv9o98yA0PagnJ708Efrd/jiL+/9rDkgQbnYi+ahNJWeRxXlAUwTEoUacGWy64rTZEmjifiD1g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blob": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/video-conversion": "^0.9.0", - "@wordpress/vips": "^5.0.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/video-conversion": "^0.10.0", + "@wordpress/vips": "^5.1.0", "uuid": "^14.0.0" }, "engines": { @@ -9787,9 +9813,9 @@ } }, "node_modules/@wordpress/url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-5.0.0.tgz", - "integrity": "sha512-oPDn3vjO7unimaiFSnwoP8Ys6TVGp3UXwF0CwRDMoYLqRIjDuPpZLoUbkPAnWnLOrNP+GjIzCm7qQNCrze/UAA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-5.1.0.tgz", + "integrity": "sha512-D46oS8q/g8kK4diyAtwTvFOY43jONdJsx7qOtoWfhpVyvMGemoiNu+MJCswH2XIUG7oTRML9YrNT50mEoCaXLQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9797,13 +9823,13 @@ } }, "node_modules/@wordpress/video-conversion": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/video-conversion/-/video-conversion-0.9.0.tgz", - "integrity": "sha512-fxU5BDB809r/AbxM70LUst3CjWpn85ugT6QebwugOlX+s7aUQ7/HZ5ON0XZE4V2nPtOeUsT1HyVDcikcbaMZbQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/video-conversion/-/video-conversion-0.10.0.tgz", + "integrity": "sha512-T9vX54HzeZumOQYcvMZky2BnPAuyWYiOD8ksvZuNyBQBr0ImldWIlorm1Q6Etab8G19Vu+jw2/wCzDB3v3SqeQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/worker-threads": "^2.0.0", + "@wordpress/worker-threads": "^2.1.0", "mediabunny": "^1.45.2" }, "engines": { @@ -9811,23 +9837,23 @@ } }, "node_modules/@wordpress/vips": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/vips/-/vips-5.0.0.tgz", - "integrity": "sha512-7he8zRvtoaI+uGCoQ6Tf5+PbuhB0OowB0h8H4HLB7yv8hl4YOxnGG1tB1ldB6gypNyukcawk+aK/CcJ2+cnI2A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/vips/-/vips-5.1.0.tgz", + "integrity": "sha512-P3ocGmDQgrGtwE9T2if47LHGTI/mVDuHC+J0EoeQM546fzxYQFXkBCR76UDFEV+kAXmoTX4U7vjP5pAyjMC3Ig==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/worker-threads": "^2.0.0", - "wasm-vips": "^0.0.18" + "@wordpress/worker-threads": "^2.1.0", + "wasm-vips": "^0.0.19" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.0.0.tgz", - "integrity": "sha512-qhAwfjMUYT1TfyMlrkO4i1IbcqfjfjLBFepXSRvMUhNbL8CRRu3cgjdZNCf82FkvUwmuCEuMsrmGisxu4CGr1A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.1.0.tgz", + "integrity": "sha512-nKzIGM+H4YIaGIQY4+KTi6pSRWC4rVm0SKz3TUbORp5WXXq18MsiUqvG4D0vq/fuzo5fhGC5/Ee/T+EPDQF9FQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9835,9 +9861,9 @@ } }, "node_modules/@wordpress/worker-threads": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/worker-threads/-/worker-threads-2.0.0.tgz", - "integrity": "sha512-ytIahXlVLdMMDfdDvOl1X4GrzVRcxc4JB+41zWJFdkNnzxDe6xbuyKujNe6kwrWFvr2YfC41wXfPViHEzB96pw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/worker-threads/-/worker-threads-2.1.0.tgz", + "integrity": "sha512-4xj15StZQJbQwb0WqvPRS5dmwozlTb45JHd8KH6TQrVKGoyQN+fKm44kXxyeGwboNrdBZDRzNNiiBiiVVg0gtw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -24644,9 +24670,9 @@ "license": "Apache-2.0" }, "node_modules/wasm-vips": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.18.tgz", - "integrity": "sha512-AJyCvxZj/3qceKNnh+YyEobu/IaJFoPN7x7SxyyHmYBS3kASMqJqxQEuN0ZHKQDWsCJ8armfx4Tq3uKrNc+nMA==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.19.tgz", + "integrity": "sha512-KeejYs8oNqPUd2C/XPRhQtRabydqOSkUyCdWhFf2ucL8rVKqHvtp5ZYuYLyeDG/4y45tvROST3mQWla+1uotSA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e5c485b..350eb1b 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "@playwright/test": "^1.64.1", "@types/node": "^26.6.5", "@types/react": "^19.3.0", - "@wordpress/block-editor": "^19.0.0", - "@wordpress/blocks": "^17.0.0", - "@wordpress/commands": "^2.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/e2e-test-utils-playwright": "^3.1.0", - "@wordpress/env": "^12.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/scripts": "^37.0.0", + "@wordpress/block-editor": "^20.0.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/commands": "^2.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/env": "^12.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/scripts": "^37.1.0", "typescript": "^6.0.3" }, "scripts": { diff --git a/phpstan.safeguards.neon b/phpstan.safeguards.neon index 8e38407..77db12a 100644 --- a/phpstan.safeguards.neon +++ b/phpstan.safeguards.neon @@ -9,6 +9,9 @@ parameters: paths: - dws-plugin-template.php - includes/safeguards.php + # A safeguard calls WooCommerce where the plugin requires it. + scanFiles: + - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' diff --git a/scoper.inc.php b/scoper.inc.php index 54aafaa..b08522f 100644 --- a/scoper.inc.php +++ b/scoper.inc.php @@ -2,6 +2,9 @@ /** * Returns the php-scoper config that prefixes the framework and PHP-DI with the plugin's namespace. * + * @since 1.0.0 + * @version 1.0.0 + * * @package DeepWebSolutions\PluginTemplate */ diff --git a/src/Plugin.php b/src/Plugin.php index f707700..3befb4b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -16,7 +16,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Composes the plugin from its logger, scheduler, migrations and components, inside the framework's boot boundary. + * Composes the plugin inside the framework's boot boundary. * * @since 1.0.0 * @version 1.0.0 @@ -191,7 +191,6 @@ protected function compose(): bool { $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } diff --git a/src/Settings.php b/src/Settings.php index f610fc7..ad36ea3 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -9,7 +9,7 @@ \defined( 'ABSPATH' ) || exit; /** - * Adds the plugin's WooCommerce settings tab, which holds the greeting's settings and the consent to delete the plugin's data on uninstall. + * Adds the plugin's WooCommerce settings tab. * * @since 1.0.0 * @version 1.0.0 @@ -116,7 +116,6 @@ public function add_settings_tab( array $pages ): array { ), $this ); - return $pages; } diff --git a/tests/EndToEnd/cli.ts b/tests/EndToEnd/cli.ts new file mode 100644 index 0000000..14aabda --- /dev/null +++ b/tests/EndToEnd/cli.ts @@ -0,0 +1,13 @@ +/// +import { execFileSync } from 'node:child_process'; + +// No screen shows some of what the specs set up or check, so they run WP-CLI on the tests site and read its last line. +export const cli = ( ...args: string[] ): string => + execFileSync( + 'node_modules/.bin/wp-env', + [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], + { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } + ) + .trim() + .split( '\n' ) + .pop() ?? ''; diff --git a/tests/EndToEnd/editor.spec.ts b/tests/EndToEnd/editor.spec.ts index f47d9a8..bb831c0 100644 --- a/tests/EndToEnd/editor.spec.ts +++ b/tests/EndToEnd/editor.spec.ts @@ -1,7 +1,24 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +const NAME = 'dws-plugin-template/example-notice'; +const TEXT = 'An example notice from DWS Plugin Template.'; + test.describe( 'Block editor', () => { - test( 'inserts the example notice from the command palette', async ( { + let post: number | null = null; + + test.afterEach( async ( { requestUtils } ) => { + if ( null !== post ) { + await requestUtils.rest( { + method: 'DELETE', + path: `/wp/v2/posts/${ post }`, + params: { force: true }, + } ); + post = null; + } + } ); + + test( 'inserts the example notice from the command palette and publishes its Outlined style', async ( { admin, editor, page, @@ -16,15 +33,33 @@ test.describe( 'Block editor', () => { .fill( 'Insert an example notice' ); await page.keyboard.press( 'Enter' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ { name: NAME } ] ); + await expect( editor.canvas.getByText( TEXT ) ).toBeVisible(); + + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Outlined' } ) + .click(); await expect .poll( editor.getBlocks ) .toMatchObject( [ - { name: 'dws-plugin-template/example-notice' }, + { name: NAME, attributes: { className: 'is-style-outlined' } }, ] ); - await expect( - editor.canvas.getByText( - 'An example notice from DWS Plugin Template.' - ) - ).toBeVisible(); + + post = await editor.publishPost(); + expect( post ).not.toBeNull(); + await page.goto( `/?p=${ post }` ); + + const notice = page.locator( + '.wp-block-dws-plugin-template-example-notice.is-style-outlined' + ); + await expect( notice ).toHaveText( TEXT ); + const results = await new AxeBuilder( { page } ) + .include( '.wp-block-dws-plugin-template-example-notice' ) + .analyze(); + expect( results.violations ).toEqual( [] ); } ); } ); diff --git a/tests/EndToEnd/without-woocommerce.spec.ts b/tests/EndToEnd/without-woocommerce.spec.ts index b557705..63e7fb6 100644 --- a/tests/EndToEnd/without-woocommerce.spec.ts +++ b/tests/EndToEnd/without-woocommerce.spec.ts @@ -1,30 +1,10 @@ -/// -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { cli } from './cli'; // WordPress core's own message for a plugin whose Requires Plugins header lists an inactive plugin. const DEPENDENCY = 'DWS Plugin Template requires 1 plugin to be installed and activated: WooCommerce.'; -// No page shows the boot's outcome, so the site's WP-CLI reads it. -const bootStatus = (): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'eval', - 'echo dws_tpl_plugin()->boundary->status;', - ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; - test.describe( 'Without WooCommerce', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.deactivatePlugin( 'woocommerce' ); @@ -37,7 +17,9 @@ test.describe( 'Without WooCommerce', () => { test( 'the plugin stays blocked, WordPress names the missing plugin and every screen still loads', async ( { page, } ) => { - expect( bootStatus() ).toBe( 'blocked' ); + expect( + cli( 'eval', 'echo dws_tpl_plugin()->boundary->status;' ) + ).toBe( 'blocked' ); const front = await page.goto( '/' ); expect( front?.status() ).toBe( 200 ); diff --git a/tests/Integration/AssetsTest.php b/tests/Integration/AssetsTest.php index 88fffd7..2282f29 100644 --- a/tests/Integration/AssetsTest.php +++ b/tests/Integration/AssetsTest.php @@ -34,12 +34,12 @@ public function test_a_script_registers_with_its_generated_dependencies_and_vers } 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', 'print' ) ); + 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( 'print', $style->args ); + self::assertSame( 'all', $style->args ); self::assertSame( 'replace', $style->extra['rtl'] ?? null ); } } diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index af1f38e..c800e29 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,6 +3,9 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -10,19 +13,27 @@ #[CoversNothing] final class AccountDashboardGreetingTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + use OrderStorageTrait; + use RestoresOptionsTrait; - protected array $stored = array(); + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); - protected int $customer = 0; + protected const string TRANSLATION = 'Translated greeting'; - protected array $orders = array(); + protected int $customer = 0; #[\Override] public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; } + public static function untouched_greetings(): array { + return array( + 'unsaved' => array( null ), + 'empty' => array( '' ), + ); + } + public static function unpaid_statuses(): array { return array( 'pending' => array( 'pending' ), @@ -34,13 +45,9 @@ public static function unpaid_statuses(): array { #[\Override] protected function setUp(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - $customer = wp_insert_user( array( - 'user_login' => 'dws_tpl_customer_' . \wp_generate_password( 8, false ), + 'user_login' => Plugin::PREFIX . '_customer_' . wp_generate_password( 8, false ), 'user_pass' => wp_generate_password(), 'role' => 'customer', ) @@ -52,21 +59,13 @@ protected function setUp(): void { #[After] protected function clean_up(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - - foreach ( $this->orders as $order ) { - $order->delete( true ); - } - wp_set_current_user( 0 ); wp_delete_user( $this->customer ); wp_dequeue_style( 'dws-plugin-template-greeting' ); wp_deregister_style( 'dws-plugin-template-greeting' ); } - public function test_the_greeting_prints_on_the_account_dashboard(): void { + public function test_the_greeting_hooks_the_account_dashboard_and_its_style(): void { $detail = new AccountDashboardGreeting(); $detail->register_hooks(); @@ -77,12 +76,12 @@ public function test_the_greeting_prints_on_the_account_dashboard(): void { } public function test_the_greeting_style_loads_on_the_account_dashboard_only(): void { - ( new AccountDashboardGreeting() )->enqueue_style(); + new AccountDashboardGreeting()->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()->enqueue_style(); } finally { remove_filter( 'woocommerce_is_account_page', '__return_true' ); } @@ -149,33 +148,47 @@ public function test_a_customer_below_the_set_number_is_not_greeted(): void { self::assertSame( '', $this->print() ); } - #[DataProvider( 'unpaid_statuses' )] - public function test_orders_that_are_not_paid_do_not_count_towards_the_set_number( string $status ): 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' ); $this->place_orders( 1 ); - $this->place_orders( 2, $status ); + $this->place_orders( 1, 'failed' ); self::assertSame( '', $this->print() ); + + $this->place_orders( 1, 'processing' ); + + self::assertNotSame( '', $this->print() ); } - public function test_an_unsaved_greeting_prints_its_translated_default(): void { - delete_option( DWS_TPL_OPTION_GREETING ); - delete_option( DWS_TPL_OPTION_GREETING_ORDERS ); + #[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' ); $this->place_orders( 1 ); + $this->place_orders( 2, $status ); - self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); + self::assertSame( '', $this->print() ); } - public function test_an_empty_greeting_prints_its_translated_default(): void { - update_option( DWS_TPL_OPTION_GREETING, '' ); + #[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' ); $this->place_orders( 1 ); + $translate = static fn (): string => self::TRANSLATION; + add_filter( 'gettext_' . Plugin::SLUG, $translate ); - self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); + try { + self::assertStringContainsString( self::TRANSLATION, $this->print() ); + } finally { + remove_filter( 'gettext_' . Plugin::SLUG, $translate ); + } } protected function place_orders( int $count, string $status = 'completed' ): void { for ( $placed = 0; $placed < $count; $placed++ ) { - $this->orders[] = wc_create_order( + $this->create_order( array( 'customer_id' => $this->customer, 'status' => $status, @@ -186,8 +199,7 @@ protected function place_orders( int $count, string $status = 'completed' ): voi protected function print(): string { \ob_start(); - ( new AccountDashboardGreeting() )->print_greeting(); - + new AccountDashboardGreeting()->print_greeting(); return (string) \ob_get_clean(); } } diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 424e6c0..0bbabec 100644 --- a/tests/Integration/Greeting/GreetingFeatureTest.php +++ b/tests/Integration/Greeting/GreetingFeatureTest.php @@ -4,7 +4,8 @@ use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; use DeepWebSolutions\PluginTemplate\Plugin; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -12,7 +13,10 @@ #[CoversNothing] final class GreetingFeatureTest extends TestCase { - protected mixed $stored = false; + use OrderStorageTrait; + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); public static function stored_toggles(): array { return array( @@ -22,16 +26,6 @@ public static function stored_toggles(): array { ); } - #[\Override] - protected function setUp(): void { - $this->stored = get_option( DWS_TPL_OPTION_GREETING_ENABLED ); - } - - #[After] - protected function restore_row(): void { - false === $this->stored ? delete_option( DWS_TPL_OPTION_GREETING_ENABLED ) : update_option( DWS_TPL_OPTION_GREETING_ENABLED, $this->stored ); - } - #[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 ); @@ -40,12 +34,41 @@ public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?strin } #[RunInSeparateProcess] - public function test_with_the_greeting_switched_on_every_declared_component_boots(): void { + 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' ); - $plugin = new Plugin(); - $plugin->boot(); + update_option( DWS_TPL_OPTION_GREETING, 'Welcome back.' ); + update_option( DWS_TPL_OPTION_GREETING_ORDERS, '1' ); + $customer = wp_insert_user( + array( + 'user_login' => Plugin::PREFIX . '_customer_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'role' => 'customer', + ) + ); + self::assertIsInt( $customer ); + + try { + $this->create_order( + array( + 'customer_id' => $customer, + 'status' => 'completed', + ) + ); + wp_set_current_user( $customer ); + $plugin = new Plugin(); + $plugin->boot(); + \ob_start(); + do_action( 'woocommerce_account_dashboard' ); + $dashboard = (string) \ob_get_clean(); - self::assertSame( 'booted', $plugin->boundary->status ); - self::assertSame( array(), $plugin->kernel?->skipped ); + self::assertSame( 'booted', $plugin->boundary->status ); + self::assertSame( array(), $plugin->kernel?->skipped ); + self::assertStringContainsString( 'Welcome back.', $dashboard ); + } finally { + wp_set_current_user( 0 ); + wp_delete_user( $customer ); + } } } diff --git a/tests/Integration/MetadataTest.php b/tests/Integration/MetadataTest.php index 1a3d70c..0deab32 100644 --- a/tests/Integration/MetadataTest.php +++ b/tests/Integration/MetadataTest.php @@ -51,7 +51,6 @@ protected static function get_listed_header( string $header ): string { protected static function translate_name(): \Closure { $name = self::get_listed_header( 'Name' ); - return static fn ( string $translation, string $text ): string => $name === $text ? self::TRANSLATION : $translation; } } diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php index 384cd32..11742c5 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,23 +11,12 @@ #[CoversNothing] final class MigrationsTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected array $stored = array(); - - #[\Override] - protected function setUp(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - + protected function restore_the_revision(): void { update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) ); } @@ -34,7 +24,7 @@ protected function restore_rows(): void { #[RunInSeparateProcess] public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untranslated_defaults_autoloaded(): void { global $wpdb; - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } update_option( DWS_TPL_OPTION_REVISION, 0 ); diff --git a/tests/Integration/MultisiteTest.php b/tests/Integration/MultisiteTest.php index 4a32311..cee4d34 100644 --- a/tests/Integration/MultisiteTest.php +++ b/tests/Integration/MultisiteTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Group; @@ -16,13 +17,13 @@ public function test_the_plugin_boots_on_another_site_of_the_network_and_stores_ $site_id = wp_insert_site( array( 'domain' => get_network()?->domain, - 'path' => get_network()?->path . 'dws-tpl-second/', + 'path' => get_network()?->path . \str_replace( '_', '-', Plugin::PREFIX ) . '-second/', ) ); self::assertIsInt( $site_id ); try { - $request = self::request( 'echo did_action( "dws_tpl/boot/succeeded" );', get_home_url( $site_id, '/' ) ); + $request = self::request( 'echo did_action( "dws_tpl/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); self::assertSame( '1', $request['last'], $request['output'] ); self::assertSame( 'no', get_blog_option( $site_id, DWS_TPL_OPTION_DELETE_DATA ) ); diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 28ea271..85eb055 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -3,19 +3,21 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class OptionsTest extends TestCase { + use RestoresOptionsTrait; + protected const string KEY = Plugin::PREFIX . '_options_test'; - protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const array OPTIONS = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); - protected array $stored = array(); + protected const string TRANSLATION = 'Translated greeting'; public static function stored_bools(): array { return array( @@ -50,6 +52,14 @@ 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' ), @@ -63,19 +73,9 @@ public static function submitted_order_counts(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::RESTORED as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { + protected function delete_the_test_option(): void { delete_option( self::KEY ); - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } } #[DataProvider( 'stored_bools' )] @@ -111,27 +111,25 @@ public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $ self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); } - public function test_a_string_without_a_stored_value_reads_its_translated_default(): void { - delete_option( DWS_TPL_OPTION_GREETING ); - - self::assertNotSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ); - self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + #[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 ); + $translate = static fn (): string => self::TRANSLATION; + add_filter( 'gettext_' . Plugin::SLUG, $translate ); - update_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 ) ); + try { + self::assertSame( self::TRANSLATION, dws_tpl_get_string_option( DWS_TPL_OPTION_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_a_string_that_is_not_a_string_reads_its_translated_default(): void { - update_option( DWS_TPL_OPTION_GREETING, array( 'Hello' ) ); - - self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), 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 ) ); } diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 7a84809..7a22840 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -97,12 +97,12 @@ public function test_a_build_error_is_logged_with_its_location_once_an_hour(): v } public function test_the_peer_getters_a_partial_deploy_defines_still_answer(): void { - $root = \dirname( DWS_TPL_FILE ) . '/src/Plugin.php'; // options.php reads it at include time, after metadata.php has loaded. - \rename( $root, "$root.hidden" ); + $include = \dirname( DWS_TPL_FILE ) . '/includes/metadata_zz-throws.php'; // Sorts right after metadata.php, so no later include loads. + \file_put_contents( $include, "boundary->status ); - self::assertSame( array( GreetingFeature::class ), $plugin->kernel?->skipped ); + self::assertSame( GreetingFeature::should_load() ? array() : array( GreetingFeature::class ), $plugin->kernel?->skipped ); } public function test_the_entry_loads_every_include_but_those_whose_names_start_with_an_underscore(): void { @@ -69,9 +69,17 @@ public function test_the_debug_constant_logs_every_level(): void { self::assertSame( self::get_threshold( new WooCommerceLogger( Plugin::SLUG, 'debug' ) ), self::get_threshold( $logger ) ); } - public function test_the_stored_data_is_at_the_revision_of_the_code(): void { - self::assertInstanceOf( MigrationRunner::class, dws_tpl_plugin()->migrations ); - self::assertSame( \count( dws_tpl_get_migration_steps() ), MigrationRunner::get_revision( DWS_TPL_OPTION_REVISION ) ); + public function test_a_request_brings_the_stored_data_to_the_revision_of_the_code(): void { + update_option( DWS_TPL_OPTION_REVISION, 0 ); + try { + $request = self::request( 'echo ' . MigrationRunner::class . '::get_revision( "' . DWS_TPL_OPTION_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() ) ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( (string) \count( dws_tpl_get_migration_steps() ), $request['last'] ); } protected static function get_threshold( WooCommerceLogger $logger ): mixed { diff --git a/tests/Integration/SafeguardRecipeTest.php b/tests/Integration/SafeguardRecipeTest.php index 07c42c0..a05893c 100644 --- a/tests/Integration/SafeguardRecipeTest.php +++ b/tests/Integration/SafeguardRecipeTest.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; +use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -9,58 +11,37 @@ #[CoversNothing] final class SafeguardRecipeTest extends TestCase { - protected const string CAPABILITY = 'dws_tpl_export_data'; - - protected const string FILTER = 'dws_tpl/data_export/should_restrict'; - #[BeforeClass] public static function load_the_recipe(): void { - $recipes = \dirname( __DIR__, 2 ) . '/docs/recipes.md'; - self::assertFileExists( $recipes ); - \preg_match( '/```php\n(<\?php declare\( strict_types=1 \);\n(?:(?!```).)*?function dws_tpl_is_relaxed\((?:(?!```).)*)```/s', (string) \file_get_contents( $recipes ), $block ); - self::assertArrayHasKey( 1, $block, 'docs/recipes.md holds no includes/safeguards.php block defining dws_tpl_is_relaxed().' ); - - $file = \sys_get_temp_dir() . '/dws-tpl-safeguard-recipe.php'; - \file_put_contents( $file, $block[1] ); - require_once $file; - } - - public static function relaxations(): array { - return array( - 'none' => array( array(), false ), - 'null' => array( array( 'relaxation' => null ), false ), - 'zero' => array( array( 'relaxation' => 0 ), false ), - 'empty-string' => array( array( 'relaxation' => '' ), false ), - 'false' => array( array( 'relaxation' => false ), true ), - ); - } - - #[DataProvider( 'relaxations' )] - public function test_only_an_explicit_false_relaxes_the_safeguard( array $spec, bool $relaxed ): void { - $relaxation = static fn (): mixed => $spec['relaxation']; - if ( \array_key_exists( 'relaxation', $spec ) ) { - add_filter( self::FILTER, $relaxation ); - } - - try { - self::assertSame( $relaxed ? array( 'manage_options' ) : array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); - } finally { - remove_filter( self::FILTER, $relaxation ); + $recipes = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/docs/recipes.md' ); + foreach ( array( 'function dws_tpl_is_relaxed(', '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] ); + require_once $file; + \unlink( $file ); } } - public function test_an_explicit_false_does_not_relax_the_safeguard_before_the_plugin_booted(): void { - global $wp_actions; - self::assertGreaterThan( 0, did_action( 'dws_tpl/boot/succeeded' ) ); - $fired = $wp_actions['dws_tpl/boot/succeeded']; - unset( $wp_actions['dws_tpl/boot/succeeded'] ); - add_filter( self::FILTER, '__return_false' ); + #[AfterClass] + public static function remove_the_recipes_safeguard(): void { + remove_filter( 'map_meta_cap', 'dws_tpl_map_export_capability' ); + } - try { - self::assertSame( array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); - } finally { - remove_filter( self::FILTER, '__return_false' ); - $wp_actions['dws_tpl/boot/succeeded'] = $fired; + // The recipe's test class is loaded from its block, so PHPUnit never discovers it and this row runs each of its rows. + public function test_the_recipes_safeguard_passes_the_recipes_test(): void { + foreach ( new \ReflectionClass( SafeguardsTest::class )->getMethods( \ReflectionMethod::IS_PUBLIC ) as $test ) { + if ( ! \str_starts_with( $test->name, 'test_' ) ) { + continue; + } + + $provider = $test->getAttributes( DataProvider::class )[0] ?? null; + $rows = \is_null( $provider ) ? array( $test->name => array() ) : SafeguardsTest::{$provider->newInstance()->methodName()}(); + foreach ( $rows as $name => $row ) { + $test->invoke( new SafeguardsTest( (string) $name ), ...$row ); + } } } } diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index e7739c3..66fb21c 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -4,19 +4,19 @@ use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class SettingsTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected static SettingsTab $tab; + 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 array $stored = array(); + protected static SettingsTab $tab; #[\Override] public static function setUpBeforeClass(): void { @@ -60,24 +60,13 @@ public static function whole_order_counts(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - - ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->setValue( null, array() ); + protected function forget_the_rejections(): void { + new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); } public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defaults(): void { - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } @@ -109,7 +98,7 @@ public function test_a_save_that_leaves_the_message_untouched_stores_no_translat self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); } - public function test_the_retention_opt_in_round_trips_through_woocommerces_save(): void { + public function test_the_uninstall_consent_round_trips_through_woocommerces_save(): void { $this->save( array( DWS_TPL_OPTION_DELETE_DATA => '1' ) ); self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); @@ -129,7 +118,7 @@ public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_k $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => $posted ) ); self::assertSame( '4', get_option( DWS_TPL_OPTION_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() ); + 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' )] @@ -143,7 +132,6 @@ public function test_a_whole_order_count_of_at_least_one_is_stored_as_its_digits protected function render(): \Dom\HTMLDocument { \ob_start(); \WC_Admin_Settings::output_fields( self::$tab->get_settings_for_section( '' ) ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index d00be34..5288540 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,6 +11,8 @@ #[CoversNothing] final class UninstallTest extends TestCase { + use RunsRequestsTrait; + protected const int USER = 1; #[AfterClass] @@ -22,7 +25,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void \define( 'WP_UNINSTALL_PLUGIN', 'another-plugin/another-plugin.php' ); // A bulk uninstall keeps the first plugin's value for every later one. $source = (string) \file_get_contents( DWS_TPL_FILE ); \file_put_contents( DWS_TPL_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); - $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); + $log = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-uninstall-' ); \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. try { @@ -73,6 +76,22 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram self::assertSame( 'no', get_option( DWS_TPL_OPTION_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' ); + + 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 ); + + self::assertSame( '[true,false]', $request['last'], $request['output'] ); + } finally { + wp_cache_flush(); + // The other tests share the database, so the runner stores again what a new site starts with. + dws_tpl_plugin()->migrations?->run(); + } + } + #[RunInSeparateProcess] public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_and_keeps_the_consent(): void { global $wpdb; diff --git a/tests/Integration/bootstrap.php b/tests/Integration/bootstrap.php index 9b272c9..4f1ce55 100644 --- a/tests/Integration/bootstrap.php +++ b/tests/Integration/bootstrap.php @@ -1,5 +1,21 @@ , as `local`, where the framework rethrows boot and migration failures. -- `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. +- `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database restores it there, or flushes the parent's object cache in `#[AfterClass]`, and the end-to-end specs set their own preconditions, because either suite may run first. - `.wp-env.multisite.json` is a network on , as `production`, with WooCommerce and the plugin network-activated, where the Integration suite's `multisite` group runs and the default run skips it. Each starts WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. @@ -40,7 +40,7 @@ Start the multisite site, build the scripts and run the Integration suite's `mul composer test:integration:multisite ``` -Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and, once per machine, Playwright's Chromium: +Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and Playwright's Chromium, installed initially and after a Playwright update that needs a new browser revision: ```sh npx playwright install chromium @@ -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. +- **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. ## Conventions diff --git a/tests/Support/OrderStorageTrait.php b/tests/Support/OrderStorageTrait.php new file mode 100644 index 0000000..1d75a7f --- /dev/null +++ b/tests/Support/OrderStorageTrait.php @@ -0,0 +1,58 @@ + array( false, false ), + 'posts-with-sync' => array( false, true ), + 'hpos' => array( true, false ), + 'hpos-with-sync' => array( true, true ), + ); + } + + protected function use_storage( bool $hpos, bool $sync ): void { + $this->storage_filters = array( + 'pre_option_woocommerce_custom_orders_table_enabled' => static fn (): string => $hpos ? 'yes' : 'no', + 'pre_option_woocommerce_custom_orders_table_data_sync_enabled' => static fn (): string => $sync ? 'yes' : 'no', + 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => 'no', + ); + foreach ( $this->storage_filters as $hook => $filter ) { + add_filter( $hook, $filter ); + } + } + + protected function create_order( array $args = array() ): \WC_Order { + $order = wc_create_order( $args + array( 'status' => 'pending' ) ); + self::assertInstanceOf( \WC_Order::class, $order ); + $this->orders[] = $order->get_id(); + return $order; + } + + // The orders go before the filters, because an order deletes from the storage it was loaded from. + #[After] + protected function delete_orders_and_restore_storage(): void { + foreach ( $this->orders as $id ) { + $order = wc_get_order( $id ); + if ( $order instanceof \WC_Order ) { + $order->delete( true ); + } + } + + $this->orders = array(); + + foreach ( $this->storage_filters as $hook => $filter ) { + remove_filter( $hook, $filter ); + } + + $this->storage_filters = array(); + } +} diff --git a/tests/Support/RestoresOptionsTrait.php b/tests/Support/RestoresOptionsTrait.php new file mode 100644 index 0000000..5410975 --- /dev/null +++ b/tests/Support/RestoresOptionsTrait.php @@ -0,0 +1,24 @@ +stored_options[ $key ] = get_option( $key ); + } + } + + #[After] + protected function restore_options(): void { + foreach ( $this->stored_options as $key => $value ) { + false === $value ? delete_option( $key ) : update_option( $key, $value ); + } + } +} diff --git a/tests/Support/RunsRequestsTrait.php b/tests/Support/RunsRequestsTrait.php index 29ad5b1..19cf52e 100644 --- a/tests/Support/RunsRequestsTrait.php +++ b/tests/Support/RunsRequestsTrait.php @@ -3,13 +3,10 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Support; trait RunsRequestsTrait { - /** - * @return array{status: int, output: string, last: string} - */ - protected static function request( string $php, string $url = '' ): array { - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ( '' === $url ? '' : ' --url=' . \escapeshellarg( $url ) ) . ' 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. + 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 array( 'status' => $status, 'output' => \implode( "\n", $output ), diff --git a/tests/Unit/ComponentTreeTest.php b/tests/Unit/ComponentTreeTest.php index 42ec07a..36cda50 100644 --- a/tests/Unit/ComponentTreeTest.php +++ b/tests/Unit/ComponentTreeTest.php @@ -13,13 +13,12 @@ public function test_no_component_appears_twice_in_the_tree_whatever_its_gates() $walked = array(); $pending = Plugin::COMPONENTS; while ( array() !== $pending ) { - $class = \array_shift( $pending ); + $class = \array_shift( $pending ); + self::assertNotContains( $class, $walked, "$class appears twice in the component tree." ); $walked[] = $class; if ( \is_a( $class, CompositeComponentInterface::class, true ) ) { - \array_push( $pending, ...$class::get_child_component_classes() ); + \array_unshift( $pending, ...$class::get_child_component_classes() ); } } - - self::assertSame( \array_values( \array_unique( $walked ) ), $walked ); } } diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index 12b1f9b..b4175a0 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -17,7 +17,7 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa } public function test_every_kind_is_one_the_uninstall_engine_removes(): void { - $doc = (string) ( new \ReflectionClass( Uninstaller::class ) )->getDocComment(); + $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 );