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
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ includes/ hook-free code as dws_tpl_ functions and DWS_TPL_ c
├── options.php the option keys, their defaults, the typed readers, the translated text and the validator
├── footprint.php dws_tpl_get_footprint(), every kind of data the plugin stores
├── migrations.php dws_tpl_get_migration_steps() and one function per step, append-only
└── assets.php the *.asset.php reader and the script and style registrars
├── assets.php the *.asset.php reader and the script and style registrars
└── metadata.php the slug, name and version getters, the plugin's surface for a peer plugin
blocks/src/, assets/src/ TypeScript and SCSS, built into blocks/build/ and assets/build/ (untracked)
models/, templates/ empty extension points
.github/scripts/fill-in-scaffold.sh the fill-in's sweep, which fill-in-scaffold.yml runs once in a new plugin and Quality rehearses here
Expand Down Expand Up @@ -51,7 +52,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run
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()`.
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.
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`).
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ The request that activates the plugin does not boot it, because WordPress includ
## Where code lives

- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. A component takes ungated services in its constructor, such as the logger, the scheduler or `AdminNotices`, which `Plugin::get_definitions()` provides.
- `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: the option keys, defaults and typed readers (`options.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`) and the asset registrars (`assets.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` and what it requires run before the floor check passes on a site without the built files.
- `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: the option keys, defaults and typed readers (`options.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`), the asset registrars (`assets.php`) and the slug, name and version getters a peer plugin reads (`metadata.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` and what it requires run before the floor check passes on a site without the built files.
- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`; `includes/assets.php` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`.
- `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials.

Expand Down
6 changes: 6 additions & 0 deletions docs/recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,12 @@ TODO: what the plugin does, for the directory's listing.
```

5. In `.github/workflows/release.yml`, pass `wp-org: true` to the reusable release. A tag then also deploys the zip and the `.wordpress-org/` assets to the directory's SVN from the `wp-org-release` environment: create it, store `SVN_USERNAME` and `SVN_PASSWORD` there and only there, and protect it with required reviewers.
6. A plugin whose earlier releases shipped another main file, such as a 1.x `bootstrap.php`, also passes that name as `archive-entry-file`, from a wordpress-configs version that has the input. The source keeps `dws-plugin-template.php`, and `uninstall.php` finds the main file by its header. The rename suits only wordpress.org distribution, because the GitHub updater reads the next release's floors from the main file at the release tag, under its installed name:

```yaml
# 1.x shipped bootstrap.php, and WordPress identifies an active plugin by its folder and file, so an update without that file would deactivate it on every site.
archive-entry-file: bootstrap.php
```

The wordpress.org build runs Plugin Check's full set but for the checks `exclude-checks` names; Plugin Check's `prefixing` and `direct_file_access` stop at PHP 8.4 syntax such as `protected(set)`, which wordpress.org's own scan may meet too. The bundled updater ships inert under `vendor-prefixed/`, which wordpress.org's reviewers may still ask to remove.

Expand Down
51 changes: 51 additions & 0 deletions includes/metadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php declare( strict_types=1 );
/**
* Defines the getters through which a peer plugin reads the plugin's slug, name and version, so the plugin's classes never become its contract.
*
* A peer calls them from plugins_loaded on, since it may load before the plugin, and behind function_exists(), since a site below the floors or with a partial build may leave them undefined. Each reads only the plugin's header, so a getter that is defined still answers when a later include fails.
*
* @since 2.0.0
* @version 2.0.0
*
* @package DeepWebSolutions\PluginTemplate
*/

defined( 'ABSPATH' ) || exit;

/**
* Returns the plugin's slug, which is the text domain its header declares.
*
* @since 2.0.0
* @version 2.0.0
*
* @return string
*/
function dws_tpl_get_plugin_slug(): string {
return get_file_data( DWS_TPL_FILE, array( 'TextDomain' => 'Text Domain' ) )['TextDomain'];
}

/**
* Returns the plugin's name from its header, translated only once init has fired, because WordPress flags translations loaded earlier.
*
* @since 2.0.0
* @version 2.0.0
*
* @return string
*/
function dws_tpl_get_plugin_name(): string {
$name = get_file_data( DWS_TPL_FILE, array( 'Name' => '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.
*
* @since 2.0.0
* @version 2.0.0
*
* @return string
*/
function dws_tpl_get_plugin_version(): string {
return get_file_data( DWS_TPL_FILE, array( 'Version' => 'Version' ) )['Version'];
}
57 changes: 57 additions & 0 deletions tests/Integration/MetadataTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php declare( strict_types=1 );

namespace DeepWebSolutions\PluginTemplate\Tests\Integration;

use DeepWebSolutions\PluginTemplate\Plugin;
use PHPUnit\Framework\Attributes\CoversNothing;
use PHPUnit\Framework\TestCase;

#[CoversNothing]
final class MetadataTest extends TestCase {
protected const string TRANSLATION = 'Translated & renamed plugin';

public function test_the_slug_is_the_text_domain_wordpress_lists(): void {
self::assertSame( self::get_listed_header( 'TextDomain' ), dws_tpl_get_plugin_slug() );
}

public function test_the_version_is_the_one_wordpress_lists(): void {
self::assertSame( self::get_listed_header( 'Version' ), dws_tpl_get_plugin_version() );
}

public function test_the_name_is_translated_once_init_has_fired(): void {
self::assertGreaterThan( 0, did_action( 'init' ) );
$translate = self::translate_name();
add_filter( 'gettext_' . Plugin::SLUG, $translate, 10, 2 );

try {
self::assertSame( self::TRANSLATION, dws_tpl_get_plugin_name() );
} finally {
remove_filter( 'gettext_' . Plugin::SLUG, $translate );
}
}

public function test_the_name_stays_as_wordpress_lists_it_before_init(): void {
global $wp_actions;
$fired = $wp_actions['init'];
unset( $wp_actions['init'] );
$translate = self::translate_name();
add_filter( 'gettext_' . Plugin::SLUG, $translate, 10, 2 );

try {
self::assertSame( self::get_listed_header( 'Name' ), dws_tpl_get_plugin_name() );
} finally {
remove_filter( 'gettext_' . Plugin::SLUG, $translate );
$wp_actions['init'] = $fired;
}
}

protected static function get_listed_header( string $header ): string {
return get_plugins()[ plugin_basename( DWS_TPL_FILE ) ][ $header ];
}

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;
}
}
13 changes: 13 additions & 0 deletions tests/Integration/PartialDeployTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ public function test_an_include_that_throws_shows_the_build_notice_and_boots_not
self::assertSame( '[false,true]', $request['last'] );
}

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" );
try {
$request = self::request( 'echo wp_json_encode( array( false !== has_action( "plugins_loaded", "dws_tpl_boot" ), dws_tpl_get_plugin_slug(), dws_tpl_get_plugin_name(), dws_tpl_get_plugin_version() ) );' );
} finally {
\rename( "$root.hidden", $root );
}

self::assertSame( 0, $request['status'], $request['output'] );
self::assertSame( wp_json_encode( array( false, dws_tpl_get_plugin_slug(), dws_tpl_get_plugin_name(), dws_tpl_get_plugin_version() ) ), $request['last'] );
}

public function test_a_deactivation_whose_composition_root_cannot_be_built_is_reported_as_a_build_error(): void {
$root = \dirname( DWS_TPL_FILE ) . '/vendor-prefixed/ahegyes/wp-framework-core/src/PluginBoot.php'; // The composition root constructs it, and no include reads it.
\rename( $root, "$root.hidden" );
Expand Down
21 changes: 21 additions & 0 deletions tests/Integration/UninstallTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,27 @@ public static function forget_the_rows_the_uninstall_deleted(): void {
wp_cache_flush(); // The uninstall ran in a child process, so this process still caches the rows it deleted, such as a setting the tab saved.
}

#[RunInSeparateProcess]
public function test_an_uninstall_checks_the_floors_of_its_own_main_file_under_any_name(): void {
\define( 'WP_UNINSTALL_PLUGIN', 'another-plugin/another-plugin.php' ); // A bulk uninstall keeps the first plugin's value for every later one.
$entry = \dirname( DWS_TPL_FILE ) . '/bootstrap.php'; // A release can ship the main file under another name.
$source = (string) \file_get_contents( DWS_TPL_FILE );
\rename( DWS_TPL_FILE, DWS_TPL_FILE . '.hidden' );
\file_put_contents( $entry, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) );
$log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' );
\ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test.

try {
include \dirname( DWS_TPL_FILE ) . '/uninstall.php';
} finally {
\unlink( $entry );
\rename( DWS_TPL_FILE . '.hidden', DWS_TPL_FILE );
}

self::assertStringContainsString( 'below its PHP or WordPress floor', (string) \file_get_contents( $log ) );
\unlink( $log );
}

#[RunInSeparateProcess]
public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_framework_roots_store(): void {
$prefix = Plugin::PREFIX . '_';
Expand Down
2 changes: 1 addition & 1 deletion tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This document describes the plugin's three test suites, the environments they ru
| Suite | Where | Runs on | Covers |
| --- | --- | --- | --- |
| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage |
| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, and a partial deploy: a missing build file or engine, or an include that throws |
| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, and a partial deploy: a missing build file or engine, or an include that throws |
| End to end | `tests/e2e/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated |

PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`.
Expand Down
18 changes: 17 additions & 1 deletion uninstall.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,24 @@

defined( 'WP_UNINSTALL_PLUGIN' ) || exit;

$dws_tpl_file = __DIR__ . '/dws-plugin-template.php';
try {
// The main file is the one with a plugin header, since a release may ship it under another name than the source's and a bulk uninstall keeps WP_UNINSTALL_PLUGIN at the first plugin it visits.
$dws_tpl_candidates = glob( __DIR__ . '/*.php' );
if ( false === $dws_tpl_candidates ) {
throw new RuntimeException( 'its folder could not be read.' );
}

$dws_tpl_entries = array();
foreach ( $dws_tpl_candidates as $dws_tpl_candidate ) {
if ( '' !== get_file_data( $dws_tpl_candidate, array( 'name' => 'Plugin Name' ) )['name'] ) {
$dws_tpl_entries[] = $dws_tpl_candidate;
}
}
if ( 1 !== count( $dws_tpl_entries ) ) {
throw new RuntimeException( 'its folder does not hold exactly one main plugin file.' );
}

$dws_tpl_file = $dws_tpl_entries[0];
$dws_tpl_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php';
if ( ! is_readable( $dws_tpl_bootstrap ) ) {
throw new RuntimeException( 'its bootstrap package is missing.' );
Expand Down
Loading