diff --git a/AGENTS.md b/AGENTS.md index 3062dbd..966f6d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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`). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ff706c1..6e6c5fa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,7 +28,7 @@ The request that activates the plugin does not boot it, because WordPress includ ## Where code lives - `src/` holds the 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. diff --git a/docs/recipes.md b/docs/recipes.md index 75a4013..dd7395c 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -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. diff --git a/includes/metadata.php b/includes/metadata.php new file mode 100644 index 0000000..1ffc6ef --- /dev/null +++ b/includes/metadata.php @@ -0,0 +1,51 @@ + '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']; +} diff --git a/tests/Integration/MetadataTest.php b/tests/Integration/MetadataTest.php new file mode 100644 index 0000000..1a3d70c --- /dev/null +++ b/tests/Integration/MetadataTest.php @@ -0,0 +1,57 @@ + $name === $text ? self::TRANSLATION : $translation; + } +} diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index cfd5b2a..132aa70 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -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" ); diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index ee6a712..72cfacf 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -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 . '_'; diff --git a/tests/README.md b/tests/README.md index 3dddf0f..6dacee3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -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/`. diff --git a/uninstall.php b/uninstall.php index 26b608e..935a3cc 100644 --- a/uninstall.php +++ b/uninstall.php @@ -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.' );