Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run

- Every PHP file opens with `<?php declare( strict_types=1 );`, uses `array()`, `final` classes, `protected` over `private` and `#[\Override]` on overrides, and groups class members in `// region NAME` blocks outside `tests/`.
- Docblocks open with one third-person sentence naming the role, such as "Returns …", plus a second paragraph only for a non-obvious why; property and constant docblocks open with "The …", constructors read "Constructor.", overrides read `{@inheritDoc}`. Every other function docblock ends with `@return`, `@return void` included; tag values align at column 10, each `@param` stays on one line, and `@throws` reads "Thrown when …". Every public `do_action()` and `apply_filters()` carries a hook docblock.
- A hook the plugin fires is named `dws_tpl/area/name`, with `/` between levels and `_` between words, as the framework names its own, and its last level says what kind of hook it is: a past participle for an event, `before_` or `after_` for a position, an imperative for a scheduled hook (`dws_tpl/records/prune`), a predicate (`is_`, `has_`, `can_`, `should_`) for a boolean filter (`dws_tpl/data_export/should_restrict`), and the noun of the value for any other filter, plural for a list.
- Inline comments state a non-obvious why in one capitalized sentence, and every file describes the present, never the change; a `phpcs:ignore` names its sniff and gives a reason.
- Markdown keeps one line per paragraph, sentence-case headings and an impersonal, present-tense voice.
- Commit subjects are capitalized imperatives of 72 characters or fewer, with no `type(scope):` prefix and no trailing period. The body says what was wrong and why before what changed, and an agent-assisted commit ends with `Assisted-by: <agent>:<model-id>`, never `Co-Authored-By`.
Expand Down
898 changes: 617 additions & 281 deletions LICENSE

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,4 @@ Every pull request also rehearses the fill-in with sample inputs and gates the f

## License

GPL-2.0-or-later.
GPL-3.0-or-later.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"name": "ahegyes/dws-plugin-template",
"description": "WordPress plugins on the wp-framework packages start here, from one tested anatomy, so all are built alike and a fix found in one applies to all.",
"type": "wordpress-plugin",
"license": "GPL-2.0-or-later",
"license": "GPL-3.0-or-later",
"homepage": "https://github.com/ahegyes/wordpress-plugin-template",
"authors": [
{
Expand Down
220 changes: 85 additions & 135 deletions composer.lock

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ This document describes how the plugin loads, boots, stores its data and leaves
`src/Plugin.php` is the composition root. Its constructor builds the logger, the scheduler and the framework's `PluginBoot`, which runs the composition and reports its outcome:

- The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked.
- `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_tpl_initialization_blocked` fires, and users who can activate plugins see why the plugin stays off.
- `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_tpl/boot/blocked` fires, and users who can activate plugins see why the plugin stays off.
- The migration runner registers, and runs pending steps on `wp_loaded`.
- The kernel builds each class in `Plugin::COMPONENTS` through a PHP-DI container and calls its `register_hooks()`; a composite adds its children, and a conditional component loads only when its static `should_load()` returns true.
- Once every component has registered, the boot is `booted` and `dws_tpl_initialized` fires. A throw anywhere in the composition makes it `failed`: `dws_tpl_initialization_failure` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it.
- Once every component has registered, the boot is `booted` and `dws_tpl/boot/succeeded` fires. A throw anywhere in the composition makes it `failed`: `dws_tpl/boot/failed` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it.

The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`; its post types, routes and migrations first run on the next request.

Expand Down
30 changes: 15 additions & 15 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ These are the changes most plugins built from this template make, each as the fi

## A safeguard

A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the autoloader, so it calls only WordPress and what that file defines. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl_initialized`, which fires once the plugin has booted, so a half-loaded build never relaxes it.
A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the autoloader, so it calls only WordPress and what that file defines. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it.

Replace `includes/safeguards.php` with the helper and the safeguard. The file must still parse on PHP 7.4, because the entry loads it before the floor gate passes on a site without the plugin's built files:

Expand Down Expand Up @@ -47,7 +47,7 @@ add_filter( 'map_meta_cap', 'dws_tpl_map_export_capability', 10, 2 );
* @return bool
*/
function dws_tpl_is_relaxed( $filtered ): bool {
return 0 < did_action( 'dws_tpl_initialized' ) && false === $filtered;
return 0 < did_action( 'dws_tpl/boot/succeeded' ) && false === $filtered;
}

/**
Expand All @@ -74,7 +74,7 @@ function dws_tpl_map_export_capability( array $caps, string $cap ): array {
*
* @param bool $restricted Whether the data export stays locked.
*/
return dws_tpl_is_relaxed( apply_filters( 'dws_tpl_restrict_data_export', true ) ) ? array( 'manage_options' ) : array( 'do_not_allow' );
return dws_tpl_is_relaxed( apply_filters( 'dws_tpl/data_export/should_restrict', true ) ) ? array( 'manage_options' ) : array( 'do_not_allow' );
}
```

Expand All @@ -83,15 +83,15 @@ Every further safeguard calls `dws_tpl_is_relaxed()` around its own literal `app
A component relaxes the lock only around the operation that needs it, and removes the relaxation even when the operation throws:

```php
add_filter( 'dws_tpl_restrict_data_export', '__return_false' );
add_filter( 'dws_tpl/data_export/should_restrict', '__return_false' );
try {
$this->export(); // Checks current_user_can( 'dws_tpl_export_data' ).
} finally {
remove_filter( 'dws_tpl_restrict_data_export', '__return_false' );
remove_filter( 'dws_tpl/data_export/should_restrict', '__return_false' );
}
```

Add the safeguard's failure test as `tests/Integration/SafeguardsTest.php`. The plugin has booted when the Integration suite runs, so the last row takes `dws_tpl_initialized` back for its duration:
Add the safeguard's failure test as `tests/Integration/SafeguardsTest.php`. The plugin has booted when the Integration suite runs, so the last row takes `dws_tpl/boot/succeeded` back for its duration:

```php
<?php declare( strict_types=1 );
Expand All @@ -104,7 +104,7 @@ use PHPUnit\Framework\TestCase;

#[CoversNothing]
final class SafeguardsTest extends TestCase {
protected const string FILTER = 'dws_tpl_restrict_data_export';
protected const string FILTER = 'dws_tpl/data_export/should_restrict';

public static function relaxations(): array {
return array(
Expand Down Expand Up @@ -132,16 +132,16 @@ final class SafeguardsTest extends TestCase {

public function test_an_explicit_false_does_not_relax_the_export_lock_before_the_plugin_booted(): void {
global $wp_actions;
self::assertGreaterThan( 0, did_action( 'dws_tpl_initialized' ) );
$fired = $wp_actions['dws_tpl_initialized'];
unset( $wp_actions['dws_tpl_initialized'] );
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' );

try {
self::assertSame( array( 'do_not_allow' ), map_meta_cap( 'dws_tpl_export_data', 0 ) );
} finally {
remove_filter( self::FILTER, '__return_false' );
$wp_actions['dws_tpl_initialized'] = $fired;
$wp_actions['dws_tpl/boot/succeeded'] = $fired;
}
}
}
Expand Down Expand Up @@ -659,7 +659,7 @@ final class Pruning implements ComponentInterface {
*
* @var string
*/
public const string HOOK = Plugin::PREFIX . '_prune';
public const string HOOK = Plugin::PREFIX . '/records/prune';

// endregion

Expand Down Expand Up @@ -757,7 +757,7 @@ final class Pruning implements ComponentInterface {
```

Then:
- Add `Plugin::PREFIX . '_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.
- 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 )`.
- 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.

Expand Down Expand Up @@ -845,8 +845,8 @@ Tested up to: TODO, the newest WordPress branch the plugin is tested on
Stable tag: TODO, the Version header
Requires at least: TODO, the Requires at least header
Requires PHP: TODO, the Requires PHP header
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
License: GPLv3 or later
License URI: https://www.gnu.org/licenses/gpl-3.0.html

TODO: the plugin's one sentence, at most 150 characters.

Expand Down
4 changes: 2 additions & 2 deletions dws-plugin-template.php
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<?php declare( strict_types=1 );
/**
* Plugin Name: DWS Plugin Template
Expand All @@ -11,8 +11,8 @@
* WC tested up to: 11.2
* Author: Contributors
* Author URI: https://github.com/ahegyes
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* License: GPL-3.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-3.0.html
* Update URI: https://github.com/ahegyes/wordpress-plugin-template
* Text Domain: dws-plugin-template
* Domain Path: /languages
Expand Down
2 changes: 1 addition & 1 deletion includes/footprint.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ function dws_tpl_get_footprint(): array {
'user_meta_site_prefixes' => array( Plugin::PREFIX . '_' ),
'user_meta_global_prefixes' => array( Plugin::PREFIX . '_' ),
'network_options' => array( Plugin::PREFIX . '_network_notices' ),
'cron_hooks' => array( Plugin::PREFIX . '_revision_run' ),
'cron_hooks' => array( Plugin::PREFIX . '_revision/migration/run' ),
'as_groups' => array( Plugin::PREFIX ),
);
}
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "@ahegyes/dws-plugin-template",
"private": true,
"description": "DWS WordPress plugin template — wp-env, script and style tooling.",
"license": "GPL-2.0-or-later",
"license": "GPL-3.0-or-later",
"repository": {
"type": "git",
"url": "git+https://github.com/ahegyes/wordpress-plugin-template.git"
Expand Down
10 changes: 5 additions & 5 deletions tests/Integration/SafeguardRecipeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
final class SafeguardRecipeTest extends TestCase {
protected const string CAPABILITY = 'dws_tpl_export_data';

protected const string FILTER = 'dws_tpl_restrict_data_export';
protected const string FILTER = 'dws_tpl/data_export/should_restrict';

#[BeforeClass]
public static function load_the_recipe(): void {
Expand Down Expand Up @@ -51,16 +51,16 @@ public function test_only_an_explicit_false_relaxes_the_safeguard( array $spec,

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_initialized' ) );
$fired = $wp_actions['dws_tpl_initialized'];
unset( $wp_actions['dws_tpl_initialized'] );
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' );

try {
self::assertSame( array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) );
} finally {
remove_filter( self::FILTER, '__return_false' );
$wp_actions['dws_tpl_initialized'] = $fired;
$wp_actions['dws_tpl/boot/succeeded'] = $fired;
}
}
}
4 changes: 2 additions & 2 deletions tests/Integration/UninstallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram
set_transient( "{$prefix}boot_failure_seeded", 1, HOUR_IN_SECONDS );
update_user_option( self::USER, "{$prefix}hidden_notices", array( 'seeded' ) );
update_user_meta( self::USER, "{$prefix}network_hidden_notices", array( 'seeded' ) );
wp_schedule_single_event( \time() + HOUR_IN_SECONDS, "{$prefix}revision_run" );
wp_schedule_single_event( \time() + HOUR_IN_SECONDS, "{$prefix}revision/migration/run" );
as_schedule_single_action( \time() + HOUR_IN_SECONDS, "{$prefix}seeded", array(), Plugin::PREFIX );

self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) );
Expand All @@ -59,7 +59,7 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram
self::assertFalse( get_transient( "{$prefix}boot_failure_seeded" ) );
self::assertFalse( get_user_option( "{$prefix}hidden_notices", self::USER ) );
self::assertSame( '', get_user_meta( self::USER, "{$prefix}network_hidden_notices", true ) );
self::assertFalse( wp_next_scheduled( "{$prefix}revision_run" ) );
self::assertFalse( wp_next_scheduled( "{$prefix}revision/migration/run" ) );
self::assertSame(
array(),
as_get_scheduled_actions(
Expand Down
3 changes: 2 additions & 1 deletion tests/Unit/FootprintTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function test_every_kind_is_one_the_uninstall_engine_removes(): void {
public function test_every_entry_is_in_the_plugin_prefix_family(): void {
$family = array(
Plugin::PREFIX . '_',
Plugin::PREFIX . '/', // Hook names put a slash after the prefix.
'_' . Plugin::PREFIX . '_', // WordPress hides meta keys that start with an underscore.
'_transient_' . Plugin::PREFIX . '_',
'_transient_timeout_' . Plugin::PREFIX . '_',
Expand All @@ -51,7 +52,7 @@ public function test_it_covers_what_the_framework_roots_store(): void {
self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_site_prefixes'] );
self::assertContains( $prefix, dws_tpl_get_footprint()['user_meta_global_prefixes'] );
self::assertContains( $prefix . 'network_notices', dws_tpl_get_footprint()['network_options'] );
self::assertContains( $prefix . 'revision_run', dws_tpl_get_footprint()['cron_hooks'] );
self::assertContains( $prefix . 'revision/migration/run', dws_tpl_get_footprint()['cron_hooks'] );
self::assertContains( Plugin::PREFIX, dws_tpl_get_footprint()['as_groups'] );
}

Expand Down
Loading