From dbe72063521668ee9ef045df02b2c77730a0d12b Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:43:33 +0100
Subject: [PATCH 01/16] Update packages
The lock held laravel/serializable-closure 2.1.0, while Packagist has
served 2.2.0 since 2026-11-10 16:11 UTC, after T-boot's check. The
declared minimum sits at the newest release, so it moves with the lock;
nothing else in either lock moved, since npm lists only TypeScript 7,
which X ruling 320 holds.
Assisted-by: Claude Code:claude-opus-5-5
---
composer.json | 2 +-
composer.lock | 14 +++++++-------
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/composer.json b/composer.json
index c8c089b..aa935cc 100644
--- a/composer.json
+++ b/composer.json
@@ -56,7 +56,7 @@
"automattic/jetpack-changelogger": "^7",
"humbug/php-scoper": "^0.18",
"johnpbloch/wordpress-core": "^7.1",
- "laravel/serializable-closure": "^2.1",
+ "laravel/serializable-closure": "^2.2",
"php-di/php-di": "^7.1.1",
"php-stubs/woocommerce-stubs": "^11.2",
"phpunit/phpunit": "^13",
diff --git a/composer.lock b/composer.lock
index f4ca067..2de0372 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "d1e6261c6b90ef84ee46e78f483994ec",
+ "content-hash": "131557a0ae2dc46ebbd539b11c6d2992",
"packages": [],
"packages-dev": [
{
@@ -1233,16 +1233,16 @@
},
{
"name": "laravel/serializable-closure",
- "version": "v2.1.0",
+ "version": "v2.2.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/serializable-closure.git",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5"
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2d5869a838bbcf37e0d8b0568fc41914e81374b5",
- "reference": "2d5869a838bbcf37e0d8b0568fc41914e81374b5",
+ "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
+ "reference": "2bfb44e8af009b553ed2c060c94b0bc3c67b4769",
"shasum": ""
},
"require": {
@@ -1290,8 +1290,8 @@
"issues": "https://github.com/laravel/serializable-closure/issues",
"source": "https://github.com/laravel/serializable-closure"
},
- "time": "2026-09-22T14:32:34+00:00",
- "published-time": "2026-09-22T14:33:13+00:00"
+ "time": "2026-10-14T21:20:29+00:00",
+ "published-time": "2026-11-10T16:11:08+00:00"
},
{
"name": "myclabs/deep-copy",
From 7d860a51ef8af801c061a3a7468c87d3af59becc Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:47:42 +0100
Subject: [PATCH 02/16] Test the example block on the server and its editor
command
The example block saves English markup that the editor shows translated,
registers its Outlined style from a separate script, and carries no
version, so its stylesheet's cache follows WordPress releases rather
than the plugin's.
These rows fail until the block renders on the server: one renders it
through translation with the Outlined class, the registration row
expects the Outlined style from block.json, a Unit row expects every
block.json to carry the entry's Version, and an end-to-end row inserts
the block through the editor's command palette (X rulings 200 and 357).
Assisted-by: Claude Code:claude-opus-5-5
---
blocks/src/example-notice/save.tsx | 10 ----------
tests/EndToEnd/editor.spec.ts | 30 ++++++++++++++++++++++++++++++
tests/Integration/BlocksTest.php | 25 +++++++++++++++++++++++++
tests/Unit/EntryHeadersTest.php | 11 +++++++++++
4 files changed, 66 insertions(+), 10 deletions(-)
delete mode 100644 blocks/src/example-notice/save.tsx
create mode 100644 tests/EndToEnd/editor.spec.ts
diff --git a/blocks/src/example-notice/save.tsx b/blocks/src/example-notice/save.tsx
deleted file mode 100644
index 9a1ba4f..0000000
--- a/blocks/src/example-notice/save.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { useBlockProps } from '@wordpress/block-editor';
-
-// The editor validates saved markup against the post's content, so a translated string would break the block in another locale.
-export default function save() {
- return (
-
- An example notice from DWS Plugin Template.
-
- );
-}
diff --git a/tests/EndToEnd/editor.spec.ts b/tests/EndToEnd/editor.spec.ts
new file mode 100644
index 0000000..f47d9a8
--- /dev/null
+++ b/tests/EndToEnd/editor.spec.ts
@@ -0,0 +1,30 @@
+import { expect, test } from '@wordpress/e2e-test-utils-playwright';
+
+test.describe( 'Block editor', () => {
+ test( 'inserts the example notice from the command palette', async ( {
+ admin,
+ editor,
+ page,
+ } ) => {
+ await admin.createNewPost();
+ await editor.canvas
+ .getByRole( 'textbox', { name: 'Add title' } )
+ .fill( 'Example' );
+ await page.keyboard.press( 'ControlOrMeta+k' );
+ await page
+ .getByRole( 'combobox', { name: 'Search commands and settings' } )
+ .fill( 'Insert an example notice' );
+ await page.keyboard.press( 'Enter' );
+
+ await expect
+ .poll( editor.getBlocks )
+ .toMatchObject( [
+ { name: 'dws-plugin-template/example-notice' },
+ ] );
+ await expect(
+ editor.canvas.getByText(
+ 'An example notice from DWS Plugin Template.'
+ )
+ ).toBeVisible();
+ } );
+} );
diff --git a/tests/Integration/BlocksTest.php b/tests/Integration/BlocksTest.php
index a513809..835afc4 100644
--- a/tests/Integration/BlocksTest.php
+++ b/tests/Integration/BlocksTest.php
@@ -23,6 +23,31 @@ public function test_the_example_block_registers_from_the_build_with_its_scripts
self::assertSame( array( 'dws-plugin-template-example-notice-editor-script' ), $block->editor_script_handles );
self::assertSame( array( 'dws-plugin-template-example-notice-editor-style' ), $block->editor_style_handles );
self::assertSame( array( 'dws-plugin-template-example-notice-style' ), $block->style_handles );
+ self::assertSame(
+ array(
+ array(
+ 'name' => 'outlined',
+ 'label' => 'Outlined',
+ ),
+ ),
+ $block->styles
+ );
+ }
+
+ public function test_the_example_block_renders_its_text_in_the_visitors_language_with_its_style(): void {
+ $translate = static fn ( string $translation, string $text, string $domain ): string => 'dws-plugin-template' === $domain && 'An example notice from DWS Plugin Template.' === $text ? 'Ein Beispielhinweis.' : $translation;
+ add_filter( 'gettext', $translate, 10, 3 );
+ try {
+ $html = do_blocks( '' );
+ } finally {
+ remove_filter( 'gettext', $translate );
+ }
+
+ $notice = \Dom\HTMLDocument::createFromString( '' . $html . '', \LIBXML_NOERROR )->querySelector( 'p' );
+
+ self::assertNotNull( $notice );
+ self::assertEqualsCanonicalizing( array( 'wp-block-dws-plugin-template-example-notice', 'is-style-outlined' ), \explode( ' ', (string) $notice->getAttribute( 'class' ) ) );
+ self::assertSame( 'Ein Beispielhinweis.', \trim( (string) $notice->textContent ) ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
}
public function test_the_block_editor_gets_the_editor_script_with_its_generated_dependencies(): void {
diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php
index c1d41f0..86d762a 100644
--- a/tests/Unit/EntryHeadersTest.php
+++ b/tests/Unit/EntryHeadersTest.php
@@ -17,4 +17,15 @@ public function test_wc_tested_up_to_names_the_locked_woocommerce(): void {
self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) );
self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] );
}
+
+ public function test_every_block_carries_the_entry_version(): void {
+ $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' );
+ $blocks = \glob( \dirname( __DIR__, 2 ) . '/blocks/src/*/block.json' );
+
+ self::assertSame( 1, \preg_match( '/^\s*\*\s*Version:\s*(\S+)\s*$/m', $entry, $header ) );
+ self::assertIsArray( $blocks );
+ foreach ( $blocks as $block ) {
+ self::assertSame( $header[1], \json_decode( (string) \file_get_contents( $block ), true, 512, \JSON_THROW_ON_ERROR )['version'] ?? null, $block );
+ }
+ }
}
From 48584251679b32de4b45df1736b85ab5c0fe8559 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:49:44 +0100
Subject: [PATCH 03/16] Render the example block on the server in the visitor's
language
The example block saved its text as English markup, because the editor
validates saved markup against the post and a translated string would
break the block in another locale, while the editor showed the text
translated. Its block.json carried no version, so WordPress versioned
its stylesheet by its own release instead of the plugin's.
The block is dynamic: block.json names render.php, which prints the
text through esc_html_e() inside the block's wrapper attributes, and
save.tsx, the save registration and the type shim's save declaration go
(X ruling 200's A240). block.json carries version 1.0.0, the entry's,
which the Unit row pins and CONTRIBUTING's release step names (A242),
and its description no longer calls the notice static. render.php joins
PHPStan's paths, as every PHP folder the plugin ships does. The display
name no longer lands in JSX text, so the fill-in stops refusing { and };
it keeps refusing < and >, which WordPress strips from the name it
shows, and Quality's comment names the editor script the name lands in.
Assisted-by: Claude Code:claude-opus-5-5
---
.github/scripts/fill-in-scaffold.sh | 8 ++++----
.github/workflows/quality.yml | 2 +-
AGENTS.md | 2 +-
CONTRIBUTING.md | 2 +-
blocks/src/declarations.d.ts | 6 ------
blocks/src/example-notice/block.json | 6 ++++--
blocks/src/example-notice/index.ts | 2 --
blocks/src/example-notice/render.php | 14 ++++++++++++++
docs/ARCHITECTURE.md | 2 +-
phpstan.dist.neon | 1 +
10 files changed, 27 insertions(+), 18 deletions(-)
create mode 100644 blocks/src/example-notice/render.php
diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh
index 740352c..aaa7aa8 100755
--- a/.github/scripts/fill-in-scaffold.sh
+++ b/.github/scripts/fill-in-scaffold.sh
@@ -30,10 +30,10 @@ done
# next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses.
[[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] ||
fail 'wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813.'
-# WordPress skips a plugin whose name is blank, and the name lands in single-quoted PHP and JavaScript strings, and in
-# JSX text.
-name_breakers="['\\\$/<>{}]"
-[[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', <, >, { or }."
+# WordPress skips a plugin whose name is blank and strips tags from the name it shows, and the name lands in
+# single-quoted PHP and JavaScript strings.
+name_breakers="['\\\$/<>]"
+[[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', < or >."
# The description lands in the entry's header docblock, which */ would close.
[[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.'
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index 953c8f3..e0d1e6a 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -98,7 +98,7 @@ jobs:
run: |
composer lint:php
composer test:unit
- # The display name lands in the block's JSX, which only a build and the script and type lints parse.
+ # The display name lands in the block's editor script, which only a build and the script and type lints parse.
- name: Build and lint the filled-in plugin's scripts
run: |
npm run build
diff --git a/AGENTS.md b/AGENTS.md
index 580dbff..333ee99 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,7 +20,7 @@ includes/ hook-free code as dws_tpl_ functions and DWS_TPL_ c
├── 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
└── 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)
+blocks/src/, assets/src/ TypeScript, SCSS and each block's render.php, built into blocks/build/ and assets/build/ (untracked)
models/, templates/ empty extension points
.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
```
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b2c0bc0..92a7b49 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,4 +18,4 @@ A release writes the fragments into a new version block of `CHANGELOG.md`, whose
composer changelog:write
```
-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 either differs from. Pushing the tag `v` on that commit starts the release.
+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 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. Pushing the tag `v` on that commit starts the release.
diff --git a/blocks/src/declarations.d.ts b/blocks/src/declarations.d.ts
index a12fe77..5e70fd6 100644
--- a/blocks/src/declarations.d.ts
+++ b/blocks/src/declarations.d.ts
@@ -8,10 +8,4 @@ declare module '@wordpress/block-editor' {
export function useBlockProps(
props?: HTMLAttributes< HTMLElement >
): HTMLAttributes< HTMLElement >;
-
- export namespace useBlockProps {
- function save(
- props?: HTMLAttributes< HTMLElement >
- ): HTMLAttributes< HTMLElement >;
- }
}
diff --git a/blocks/src/example-notice/block.json b/blocks/src/example-notice/block.json
index cbd478e..b32cc6c 100644
--- a/blocks/src/example-notice/block.json
+++ b/blocks/src/example-notice/block.json
@@ -2,10 +2,11 @@
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "dws-plugin-template/example-notice",
+ "version": "1.0.0",
"title": "Example Notice",
"category": "widgets",
"icon": "info",
- "description": "Displays a static example notice.",
+ "description": "Displays an example notice.",
"attributes": {},
"example": {},
"supports": {
@@ -14,5 +15,6 @@
"textdomain": "dws-plugin-template",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
- "style": "file:./style-index.css"
+ "style": "file:./style-index.css",
+ "render": "file:./render.php"
}
diff --git a/blocks/src/example-notice/index.ts b/blocks/src/example-notice/index.ts
index c8c36a8..b1f49ea 100644
--- a/blocks/src/example-notice/index.ts
+++ b/blocks/src/example-notice/index.ts
@@ -1,11 +1,9 @@
import { registerBlockType } from '@wordpress/blocks';
import Edit from './edit';
-import save from './save';
import metadata from './block.json';
import './style.scss';
registerBlockType( metadata, {
edit: Edit,
- save,
} );
diff --git a/blocks/src/example-notice/render.php b/blocks/src/example-notice/render.php
new file mode 100644
index 0000000..96c92d5
--- /dev/null
+++ b/blocks/src/example-notice/render.php
@@ -0,0 +1,14 @@
+
+>
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 3649c56..8fc4560 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -29,7 +29,7 @@ The request that activates the plugin does not boot it, because WordPress includ
- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. A component takes ungated services in its constructor, such as the logger, the scheduler or `AdminNotices`, which `Plugin::get_definitions()` provides.
- `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: the option keys, defaults and typed readers (`options.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`), the asset registrars (`assets.php`) and the slug, name and version getters a peer plugin reads (`metadata.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` runs before the floor check, and `phpstan.safeguards.neon` analyzes it at PHP 7.4 and the oldest WordPress the bootstrap package runs on.
-- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`; `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`.
+- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`, and each block's `render.php`, which it copies beside the block's build and WordPress runs on each request, so the block's text follows the visitor's language; `includes/assets.php` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`.
- `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials.
## Data
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 2f49558..762f33d 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -7,6 +7,7 @@ includes:
parameters:
paths:
+ - blocks/src
- functions.php
- uninstall.php
- includes
From 60d4786419f172701245e016145c35d98cdc6299 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:51:04 +0100
Subject: [PATCH 04/16] Declare the Outlined style in block.json and give the
editor a command
The example editor script registered the example block's Outlined
style, which block.json's styles declare natively with a translated
label, so one concept lived in two folders and the script did nothing a
script alone can do. A style on a core block, the ruled example, would
not either: register_block_style() adds one with its stylesheet in PHP.
block.json declares the Outlined style (X ruling 200's A243). The
editor script registers an "Insert an example notice" command in the
editor's command palette, whose callback inserts the example block
through the block editor's store (X ruling 357), still through
dws_tpl_register_script() and its translations. @wordpress/commands and
@wordpress/data join the devDependencies, the block-editor type shim
declares the store, and the scaffold README, tests/README.md and the
Blocks docblock describe the script.
Assisted-by: Claude Code:claude-opus-5-5
---
README.scaffold.md | 4 ++--
assets/src/editor.ts | 17 +++++++++++++----
blocks/src/declarations.d.ts | 17 ++++++++++++++++-
blocks/src/example-notice/block.json | 6 ++++++
package-lock.json | 2 ++
package.json | 2 ++
src/Blocks.php | 2 +-
tests/README.md | 2 +-
8 files changed, 43 insertions(+), 9 deletions(-)
diff --git a/README.scaffold.md b/README.scaffold.md
index ffec189..d071aae 100644
--- a/README.scaffold.md
+++ b/README.scaffold.md
@@ -54,8 +54,8 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas
| Part | Where it lives | Removing it |
| --- | --- | --- |
| The greeting | `src/Feature/`, the Greeting section of `src/Settings.php`, and the `DWS_TPL_OPTION_FEATURE`, `DWS_TPL_OPTION_GREETING` and `DWS_TPL_OPTION_GREETING_ORDERS` keys in `includes/options.php` | Delete `src/Feature/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, text and validator, their lines in `dws_tpl_add_settings()`, and `tests/Integration/Feature/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. |
-| The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, and `BlocksTest`'s block rows. |
-| The editor script | `assets/src/editor.ts`, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, and `BlocksTest`'s editor row. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. |
+| The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` and rendered by its `render.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, `BlocksTest`'s block rows, and the editor script's command, which inserts the block, with `tests/EndToEnd/editor.spec.ts`. |
+| The editor script | `assets/src/editor.ts`, a command in the editor's command palette that inserts the example block, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `@wordpress/commands` and `@wordpress/data` from `package.json`'s `devDependencies`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, `BlocksTest`'s editor row and `tests/EndToEnd/editor.spec.ts`. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. |
| `models/` and `templates/` | Empty extension points, for classes on Composer's classmap and for template partials | Delete the folders the plugin never fills, and `models/` from `composer.json`'s `autoload.classmap` and `phpstan.dist.neon`'s `paths`, since Composer and PHPStan both fail on a missing folder. |
| The fill-in | `.github/workflows/fill-in-scaffold.yml`, `.github/scripts/fill-in-scaffold.sh` and Quality's `scaffold` job | Delete all three: GitHub refuses the fill-in's own token for workflow changes, so the fill-in cannot remove them, and the job skips itself outside the template. |
diff --git a/assets/src/editor.ts b/assets/src/editor.ts
index b01fec1..07ca759 100644
--- a/assets/src/editor.ts
+++ b/assets/src/editor.ts
@@ -1,7 +1,16 @@
-import { registerBlockStyle } from '@wordpress/blocks';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { createBlock } from '@wordpress/blocks';
+import { store as commandsStore } from '@wordpress/commands';
+import { dispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
-registerBlockStyle( 'dws-plugin-template/example-notice', {
- name: 'outlined',
- label: __( 'Outlined', 'dws-plugin-template' ),
+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' )
+ );
+ close();
+ },
} );
diff --git a/blocks/src/declarations.d.ts b/blocks/src/declarations.d.ts
index 5e70fd6..38c0da6 100644
--- a/blocks/src/declarations.d.ts
+++ b/blocks/src/declarations.d.ts
@@ -1,10 +1,25 @@
// 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 are declared here.
+// @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/block.json b/blocks/src/example-notice/block.json
index b32cc6c..7c99149 100644
--- a/blocks/src/example-notice/block.json
+++ b/blocks/src/example-notice/block.json
@@ -12,6 +12,12 @@
"supports": {
"html": false
},
+ "styles": [
+ {
+ "name": "outlined",
+ "label": "Outlined"
+ }
+ ],
"textdomain": "dws-plugin-template",
"editorScript": "file:./index.js",
"editorStyle": "file:./index.css",
diff --git a/package-lock.json b/package-lock.json
index 2c386ca..0275cde 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,8 @@
"@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",
diff --git a/package.json b/package.json
index 1879b51..e5c485b 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,8 @@
"@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",
diff --git a/src/Blocks.php b/src/Blocks.php
index 5bdafd2..ca39586 100644
--- a/src/Blocks.php
+++ b/src/Blocks.php
@@ -7,7 +7,7 @@
\defined( 'ABSPATH' ) || exit;
/**
- * Registers the plugin's blocks from their build manifest, and the block-editor script that adds their styles.
+ * Registers the plugin's blocks from their build manifest, and the block-editor script.
*
* @since 1.0.0
* @version 1.0.0
diff --git a/tests/README.md b/tests/README.md
index 3a5db9e..582e94d 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -8,7 +8,7 @@ This document describes the plugin's three test suites, the environments they ru
| --- | --- | --- | --- |
| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's `WC tested up to` against the locked WooCommerce |
| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, and the boot and settings on a second site of a network |
-| End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated |
+| End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, the example block inserted from the editor's command palette, and 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/`.
From 1a25c6c5f4ea63e5b3e2f4b5e7a609c3f145802c Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:51:09 +0100
Subject: [PATCH 05/16] Outline only the default style in the editor
The editor-only dashed outline also covered the Outlined style, so in
the editor and the inserter's preview that style looked dashed rather
than as it renders.
The outline applies to :not(.is-style-outlined) (X ruling 200's A247).
Assisted-by: Claude Code:claude-opus-5-5
---
blocks/src/example-notice/editor.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/blocks/src/example-notice/editor.scss b/blocks/src/example-notice/editor.scss
index 1a20a8c..3cf86ff 100644
--- a/blocks/src/example-notice/editor.scss
+++ b/blocks/src/example-notice/editor.scss
@@ -1,3 +1,3 @@
-.wp-block-dws-plugin-template-example-notice {
+.wp-block-dws-plugin-template-example-notice:not(.is-style-outlined) {
outline: 1px dashed currentcolor;
}
From 5d016c7e8e696f5e4564c54b1bd27186a0a750ed Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:52:29 +0100
Subject: [PATCH 06/16] Test the greeting's paid orders and the enqueues behind
registration
The greeting counts every order a customer has placed, failed,
cancelled and draft orders included; its stylesheet loads on the
account page for visitors who see no greeting; and the template's own
calls enqueue a handle whether or not their registrar registered it,
so a handle another plugin took first is enqueued as the plugin's.
These rows fail until the greeting counts only paid orders, the
stylesheet skips logged-out visitors and both enqueues follow their
registrar (X ruling 200's A241, A245 and A246). The greeting's other
rows place completed orders, and the stylesheet's cleanup runs after
every row.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/BlocksTest.php | 8 +++
.../Integration/Feature/FeatureDetailTest.php | 56 +++++++++++++++++--
2 files changed, 60 insertions(+), 4 deletions(-)
diff --git a/tests/Integration/BlocksTest.php b/tests/Integration/BlocksTest.php
index 835afc4..baaf851 100644
--- a/tests/Integration/BlocksTest.php
+++ b/tests/Integration/BlocksTest.php
@@ -64,4 +64,12 @@ public function test_the_block_editor_gets_the_editor_script_with_its_generated_
self::assertSame( $asset['dependencies'], wp_scripts()->registered[ Plugin::SLUG . '-editor' ]->deps );
self::assertSame( $asset['version'], wp_scripts()->registered[ Plugin::SLUG . '-editor' ]->ver );
}
+
+ public function test_the_block_editor_gets_only_an_editor_script_the_plugin_registered(): void {
+ wp_register_script( Plugin::SLUG . '-editor', 'https://example.org/other.js', array(), '1', true );
+
+ new Blocks()->enqueue_editor_script();
+
+ self::assertFalse( wp_script_is( Plugin::SLUG . '-editor', 'enqueued' ) );
+ }
}
diff --git a/tests/Integration/Feature/FeatureDetailTest.php b/tests/Integration/Feature/FeatureDetailTest.php
index 00934fd..eba3841 100644
--- a/tests/Integration/Feature/FeatureDetailTest.php
+++ b/tests/Integration/Feature/FeatureDetailTest.php
@@ -5,6 +5,7 @@
use DeepWebSolutions\PluginTemplate\Feature\FeatureDetail;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\CoversNothing;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
#[CoversNothing]
@@ -22,6 +23,15 @@ public static function setUpBeforeClass(): void {
require_once ABSPATH . 'wp-admin/includes/user.php';
}
+ public static function unpaid_statuses(): array {
+ return array(
+ 'pending' => array( 'pending' ),
+ 'failed' => array( 'failed' ),
+ 'cancelled' => array( 'cancelled' ),
+ 'checkout-draft' => array( 'checkout-draft' ),
+ );
+ }
+
#[\Override]
protected function setUp(): void {
foreach ( self::KEYS as $key ) {
@@ -52,6 +62,8 @@ protected function clean_up(): void {
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 {
@@ -77,8 +89,6 @@ public function test_the_greeting_style_loads_on_the_account_dashboard_only(): v
self::assertTrue( wp_style_is( 'dws-plugin-template-greeting' ) );
self::assertSame( plugins_url( 'assets/build/greeting.css', DWS_TPL_FILE ), wp_styles()->registered['dws-plugin-template-greeting']->src );
- wp_dequeue_style( 'dws-plugin-template-greeting' );
- wp_deregister_style( 'dws-plugin-template-greeting' );
}
public function test_the_greeting_style_skips_the_account_page_endpoints(): void {
@@ -96,6 +106,30 @@ public function test_the_greeting_style_skips_the_account_page_endpoints(): void
self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) );
}
+ public function test_the_greeting_style_skips_logged_out_visitors(): void {
+ wp_set_current_user( 0 );
+ add_filter( 'woocommerce_is_account_page', '__return_true' );
+ try {
+ new FeatureDetail()->enqueue_style();
+ } finally {
+ remove_filter( 'woocommerce_is_account_page', '__return_true' );
+ }
+
+ self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) );
+ }
+
+ public function test_the_greeting_style_enqueues_only_a_stylesheet_it_registered(): void {
+ wp_register_style( 'dws-plugin-template-greeting', 'https://example.org/other.css', array(), '1' );
+ add_filter( 'woocommerce_is_account_page', '__return_true' );
+ try {
+ new FeatureDetail()->enqueue_style();
+ } finally {
+ remove_filter( 'woocommerce_is_account_page', '__return_true' );
+ }
+
+ self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) );
+ }
+
public function test_a_customer_whose_orders_reach_the_set_number_is_greeted_as_text(): void {
update_option( DWS_TPL_OPTION_GREETING, 'Hi & welcome' );
update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' );
@@ -115,6 +149,15 @@ 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 {
+ update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' );
+ $this->place_orders( 1 );
+ $this->place_orders( 2, $status );
+
+ self::assertSame( '', $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 );
@@ -130,9 +173,14 @@ public function test_an_empty_greeting_prints_its_translated_default(): void {
self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() );
}
- protected function place_orders( int $count ): void {
+ protected function place_orders( int $count, string $status = 'completed' ): void {
for ( $placed = 0; $placed < $count; $placed++ ) {
- $this->orders[] = wc_create_order( array( 'customer_id' => $this->customer ) );
+ $this->orders[] = wc_create_order(
+ array(
+ 'customer_id' => $this->customer,
+ 'status' => $status,
+ )
+ );
}
}
From 3a519c02321e49c0fceced82726a79f852a9ef38 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:53:13 +0100
Subject: [PATCH 07/16] Greet only paid orders and enqueue only what the plugin
registered
The greeting thanked customers whose only orders had failed, were
cancelled or were drafts, since wc_get_customer_order_count() counts
every status. Its stylesheet loaded on the account page for visitors
who are never greeted, and the editor script and the stylesheet were
enqueued even when their registrar had registered nothing, so a handle
another plugin took first was enqueued as the plugin's.
The greeting counts the customer's orders in WooCommerce's paid
statuses, up to the set number (X ruling 200's A241); the stylesheet
skips logged-out visitors (A245); and both enqueues run only when their
registrar returns true, as the ports' do (A246).
Assisted-by: Claude Code:claude-opus-5-5
---
src/Blocks.php | 5 +++--
src/Feature/FeatureDetail.php | 28 +++++++++++++++++++++-------
2 files changed, 24 insertions(+), 9 deletions(-)
diff --git a/src/Blocks.php b/src/Blocks.php
index ca39586..130a54b 100644
--- a/src/Blocks.php
+++ b/src/Blocks.php
@@ -52,8 +52,9 @@ public function register_blocks(): void {
* @return void
*/
public function enqueue_editor_script(): void {
- dws_tpl_register_script( Plugin::SLUG . '-editor', 'assets/build/editor.js' );
- wp_enqueue_script( Plugin::SLUG . '-editor' );
+ if ( dws_tpl_register_script( Plugin::SLUG . '-editor', 'assets/build/editor.js' ) ) {
+ wp_enqueue_script( Plugin::SLUG . '-editor' );
+ }
}
// endregion
diff --git a/src/Feature/FeatureDetail.php b/src/Feature/FeatureDetail.php
index be3f1d4..5e3bc93 100644
--- a/src/Feature/FeatureDetail.php
+++ b/src/Feature/FeatureDetail.php
@@ -8,7 +8,7 @@
\defined( 'ABSPATH' ) || exit;
/**
- * Greets customers on their account dashboard once they have placed the set number of orders.
+ * Greets customers on their account dashboard once the set number of their orders are paid.
*
* @since 1.0.0
* @version 1.0.0
@@ -33,7 +33,7 @@ public function register_hooks(): void {
// region HOOKS
/**
- * Prints the greeting as a WooCommerce notice to a customer whose orders reach the set number.
+ * Prints the greeting as a WooCommerce notice to a customer whose paid orders reach the set number.
*
* @since 1.0.0
* @version 1.0.0
@@ -42,7 +42,20 @@ public function register_hooks(): void {
*/
public function print_greeting(): void {
$greeting = dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING );
- if ( '' === $greeting || wc_get_customer_order_count( get_current_user_id() ) < dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) ) {
+ if ( '' === $greeting ) {
+ return;
+ }
+
+ $threshold = dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS );
+ $paid = wc_get_orders(
+ array(
+ 'customer_id' => get_current_user_id(),
+ 'status' => wc_get_is_paid_statuses(),
+ 'limit' => $threshold,
+ 'return' => 'ids',
+ )
+ );
+ if ( ! \is_array( $paid ) || \count( $paid ) < $threshold ) {
return;
}
@@ -50,7 +63,7 @@ public function print_greeting(): void {
}
/**
- * Enqueues the greeting's stylesheet on the account dashboard.
+ * Enqueues the greeting's stylesheet on a logged-in customer's account dashboard.
*
* @since 1.0.0
* @version 1.0.0
@@ -58,12 +71,13 @@ public function print_greeting(): void {
* @return void
*/
public function enqueue_style(): void {
- if ( ! is_account_page() || is_wc_endpoint_url() ) {
+ if ( ! is_user_logged_in() || ! is_account_page() || is_wc_endpoint_url() ) {
return;
}
- dws_tpl_register_style( Plugin::SLUG . '-greeting', 'assets/build/greeting.css' );
- wp_enqueue_style( Plugin::SLUG . '-greeting' );
+ if ( dws_tpl_register_style( Plugin::SLUG . '-greeting', 'assets/build/greeting.css' ) ) {
+ wp_enqueue_style( Plugin::SLUG . '-greeting' );
+ }
}
// endregion
From 358405dbb2f2fbcbdea2672a74d849f4e18dfc7d Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:53:45 +0100
Subject: [PATCH 08/16] Test the settings' wording, whole-number forms and
build notice
The greeting's default is explained only by a placeholder, the toggle
row repeats the section's title, the consent field drops the warning
the ports give, the threshold's description counts unpaid orders, the
validator refuses whole numbers the number input sends, such as 03 and
3.0, and the build notice words its outcome unlike the framework's.
These rows fail until the fields read as X ruling 201 words them
(A248, A249, A252, and the threshold under A241), the validator stores
any whole number of at least 1 as its digits (A250), and the build
notice follows the framework's sentence (A254); 1e20, which no integer
holds, stays refused.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/PartialDeployTest.php | 15 ++++++++++++
tests/Integration/SettingsTest.php | 32 +++++++++++++++++++++++--
2 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php
index 7659cfd..7a84809 100644
--- a/tests/Integration/PartialDeployTest.php
+++ b/tests/Integration/PartialDeployTest.php
@@ -13,6 +13,21 @@
final class PartialDeployTest extends TestCase {
use RunsRequestsTrait;
+ public function test_the_build_notice_says_the_features_stay_off_until_the_plugin_is_reinstalled(): void {
+ wp_set_current_user( 1 );
+ \ob_start();
+ try {
+ dws_tpl_render_build_notice();
+ } finally {
+ $notice = (string) \ob_get_clean();
+ wp_set_current_user( 0 );
+ }
+
+ $sentence = \Dom\HTMLDocument::createFromString( '' . $notice . '', \LIBXML_NOERROR )->querySelector( '.notice-error p' );
+
+ self::assertSame( 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.', $sentence?->textContent ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
+ }
+
public function test_a_missing_blocks_manifest_shows_the_build_notice_and_loads_nothing_built(): void {
$manifest = \dirname( DWS_TPL_FILE ) . '/blocks/build/blocks-manifest.php';
\rename( $manifest, "$manifest.hidden" );
diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php
index 9125696..a018c75 100644
--- a/tests/Integration/SettingsTest.php
+++ b/tests/Integration/SettingsTest.php
@@ -38,6 +38,16 @@ public static function invalid_order_counts(): array {
'decimal' => array( '2.5' ),
'word' => array( 'three' ),
'empty' => array( '' ),
+ 'too-big' => array( '1e20' ),
+ );
+ }
+
+ public static function whole_order_counts(): array {
+ return array(
+ 'padded' => array( ' 3 ' ),
+ 'leading-zero' => array( '03' ),
+ 'zero-decimals' => array( '3.0' ),
+ 'exponent' => array( '0.3e1' ),
);
}
@@ -73,6 +83,23 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau
self::assertFalse( $form->getElementById( DWS_TPL_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) );
}
+ public function test_the_tab_words_each_field_by_what_it_does(): void {
+ $form = $this->render();
+ $rows = array(
+ DWS_TPL_OPTION_FEATURE => array( 'Show the greeting', 'Greet returning customers' ),
+ DWS_TPL_OPTION_GREETING => array( 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ),
+ DWS_TPL_OPTION_GREETING_ORDERS => array( 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ),
+ DWS_TPL_OPTION_DELETE_DATA => array( 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ),
+ );
+
+ foreach ( $rows as $key => $texts ) {
+ $row = (string) $form->getElementById( $key )?->closest( 'tr' )?->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
+ foreach ( $texts as $text ) {
+ self::assertStringContainsString( $text, $row, $key );
+ }
+ }
+ }
+
public function test_a_save_that_leaves_the_message_untouched_stores_no_translated_text(): void {
delete_option( DWS_TPL_OPTION_GREETING );
@@ -105,8 +132,9 @@ public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_k
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() );
}
- public function test_a_whole_order_count_of_at_least_one_is_stored(): void {
- $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => ' 3 ' ) );
+ #[DataProvider( 'whole_order_counts' )]
+ public function test_a_whole_order_count_of_at_least_one_is_stored_as_its_digits( string $posted ): void {
+ $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => $posted ) );
self::assertSame( '3', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) );
self::assertSame( 3, dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) );
From 6026f8e3545d2aed91a4bd034d5dcb26a750a24b Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:55:09 +0100
Subject: [PATCH 09/16] Word the settings by their effect and accept any
whole-number form
The greeting's default lived only in a placeholder that phones cut off,
the toggle row repeated the section's title, the consent field dropped
the warning v1 and the ports give, the threshold's description still
counted every placed order, the validator refused whole numbers the
number input sends, such as 03 and 3.0, and the build notice worded its
outcome unlike the framework's notices.
The message field explains its empty value (X ruling 201's A248), the
consent field reads as the ports' (A249), the toggle row reads "Show
the greeting" (A252), and the threshold names paid orders (A241). The
validator parses the submission as a float and stores any whole number
of at least 1 as its digits (A250), as both recipes' validators do now,
and the build notice says the features stay off until the plugin is
reinstalled (A254). The framework's rejection sentence already confirms
the other changes (A251).
Assisted-by: Claude Code:claude-opus-5-5
---
docs/recipes.md | 22 ++++++++++++++++------
dws-plugin-template.php | 2 +-
includes/options.php | 11 ++++++++---
src/Settings.php | 9 +++++----
4 files changed, 30 insertions(+), 14 deletions(-)
diff --git a/docs/recipes.md b/docs/recipes.md
index 8f54ecc..fbd4c9a 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -256,6 +256,8 @@ function dws_tpl_get_int_setting( string $field ): int {
/**
* 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
*
@@ -264,9 +266,12 @@ function dws_tpl_get_int_setting( string $field ): int {
* @return int|WP_Error
*/
function dws_tpl_validate_limit( $submitted ) {
- $limit = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ) : false;
+ $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 false === $limit ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ) : $limit;
+ return (int) $limit;
}
```
@@ -1097,8 +1102,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa
array(
'type' => 'checkbox',
'id' => DWS_TPL_SETTING_DELETE_DATA,
- 'title' => __( 'Data on uninstall', 'dws-plugin-template' ),
- 'desc' => __( 'Delete the plugin\'s data when it is uninstalled', 'dws-plugin-template' ),
+ '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' ),
),
),
),
@@ -1205,6 +1210,8 @@ function dws_tpl_get_int_setting( string $field ): int {
/**
* 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
*
@@ -1213,9 +1220,12 @@ function dws_tpl_get_int_setting( string $field ): int {
* @return int|WP_Error
*/
function dws_tpl_validate_limit( $submitted ) {
- $limit = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ) : false;
+ $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 false === $limit ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ) : $limit;
+ return (int) $limit;
}
/**
diff --git a/dws-plugin-template.php b/dws-plugin-template.php
index 567d84e..b325246 100644
--- a/dws-plugin-template.php
+++ b/dws-plugin-template.php
@@ -96,5 +96,5 @@ function dws_tpl_render_build_notice(): void {
return;
}
- printf( '', esc_html__( 'DWS Plugin Template is missing some of its files, so its features are off. Reinstall the plugin to restore them.', 'dws-plugin-template' ) );
+ printf( '', esc_html__( 'DWS Plugin Template is missing some of its files, so its features stay off until the plugin is reinstalled.', 'dws-plugin-template' ) );
}
diff --git a/includes/options.php b/includes/options.php
index 0078f7b..5bb0fd4 100644
--- a/includes/options.php
+++ b/includes/options.php
@@ -139,7 +139,9 @@ function dws_tpl_get_option_text( string $key ): string {
}
/**
- * Returns the number of orders before the greeting shows in the form WooCommerce stores, or an error when the submission is not a whole number of at least 1.
+ * Returns the number of orders before the greeting shows as the digits WooCommerce stores, or an error when the submission is not a whole number of at least 1.
+ *
+ * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float.
*
* @since 1.0.0
* @version 1.0.0
@@ -149,9 +151,12 @@ function dws_tpl_get_option_text( string $key ): string {
* @return string|WP_Error
*/
function dws_tpl_validate_greeting_orders( $submitted ) {
- $orders = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => 1 ) ) ) : false;
+ $orders = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_FLOAT ) : false;
+ if ( false === $orders || $orders < 1 || $orders >= PHP_INT_MAX || floor( $orders ) !== $orders ) {
+ return new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) );
+ }
- return false === $orders ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ) : (string) $orders;
+ return (string) (int) $orders;
}
/**
diff --git a/src/Settings.php b/src/Settings.php
index 60ba651..f168161 100644
--- a/src/Settings.php
+++ b/src/Settings.php
@@ -46,7 +46,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array
array(
'type' => 'checkbox',
'id' => DWS_TPL_OPTION_FEATURE,
- 'title' => __( 'Greeting', 'dws-plugin-template' ),
+ 'title' => __( 'Show the greeting', 'dws-plugin-template' ),
'desc' => __( 'Greet returning customers', 'dws-plugin-template' ),
'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_FEATURE ] ),
),
@@ -54,6 +54,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array
'type' => 'text',
'id' => DWS_TPL_OPTION_GREETING,
'title' => __( 'Message', 'dws-plugin-template' ),
+ 'desc' => __( 'Leave empty to show the default greeting in each customer\'s language.', 'dws-plugin-template' ),
'default' => '',
'placeholder' => dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), // A default would be saved in the admin's language with the first save of the tab.
),
@@ -61,7 +62,7 @@ public function get_section_fields( string $tab_id, string $section_id ): array
'type' => 'number',
'id' => DWS_TPL_OPTION_GREETING_ORDERS,
'title' => __( 'Orders before the greeting', 'dws-plugin-template' ),
- 'desc' => __( 'Customers see the greeting once they have placed this many orders.', 'dws-plugin-template' ),
+ 'desc' => __( 'Customers see the greeting once this many of their orders are paid.', 'dws-plugin-template' ),
'default' => (string) DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ],
'custom_attributes' => array(
'min' => '1',
@@ -81,8 +82,8 @@ public function get_section_fields( string $tab_id, string $section_id ): array
array(
'type' => 'checkbox',
'id' => DWS_TPL_OPTION_DELETE_DATA,
- 'title' => __( 'Data on uninstall', 'dws-plugin-template' ),
- 'desc' => __( 'Delete the plugin\'s data when it is uninstalled', 'dws-plugin-template' ),
+ 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ),
+ 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ),
'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ] ),
),
array(
From 829f5796ef3f79d1d02cde5e024dfc3076874db2 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:56:01 +0100
Subject: [PATCH 10/16] Name the revision option with a constant
The migration runner's revision option had no constant, so its literal
was built again in the composition root, the footprint and four test
classes, and every port copied each of them.
DWS_TPL_OPTION_REVISION, written as its literal, holds the key in
includes/options.php; the composition root, the footprint's cron hook,
FootprintTest and the Integration rows read it, and the Without
WooCommerce recipe's options.php and composition keep it (X ruling
203's A262).
Assisted-by: Claude Code:claude-opus-5-5
---
docs/recipes.md | 12 +++++++++++-
includes/footprint.php | 2 +-
includes/options.php | 10 ++++++++++
src/Plugin.php | 2 +-
tests/Integration/MigrationsTest.php | 7 +++----
tests/Integration/PluginBootTest.php | 2 +-
tests/Integration/UninstallTest.php | 6 +++---
tests/Unit/FootprintTest.php | 2 +-
8 files changed, 31 insertions(+), 12 deletions(-)
diff --git a/docs/recipes.md b/docs/recipes.md
index fbd4c9a..c91c8ac 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -1019,7 +1019,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra
*/
protected function compose(): bool {
$this->scheduler->register_hooks();
- $this->migrations = new MigrationRunner( DWS_TPL_FILE, self::PREFIX . '_revision', dws_tpl_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( DWS_TPL_FILE, DWS_TPL_OPTION_REVISION, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
@@ -1132,6 +1132,16 @@ use DeepWebSolutions\PluginTemplate\Plugin;
defined( 'ABSPATH' ) || exit;
+/**
+ * The option in which the migration runner records the revision the plugin's data has reached.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var string
+ */
+const DWS_TPL_OPTION_REVISION = 'dws_tpl_revision';
+
/**
* The option that holds the settings page's fields, keyed by field id.
*
diff --git a/includes/footprint.php b/includes/footprint.php
index dac6f3c..71700f5 100644
--- a/includes/footprint.php
+++ b/includes/footprint.php
@@ -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/migration/run' ),
+ 'cron_hooks' => array( DWS_TPL_OPTION_REVISION . '/migration/run' ),
'as_groups' => array( Plugin::PREFIX ),
);
}
diff --git a/includes/options.php b/includes/options.php
index 5bb0fd4..0dc2845 100644
--- a/includes/options.php
+++ b/includes/options.php
@@ -52,6 +52,16 @@
*/
const DWS_TPL_OPTION_GREETING_ORDERS = Plugin::PREFIX . '_greeting_orders';
+/**
+ * The option in which the migration runner records the revision the plugin's data has reached.
+ *
+ * @since 1.0.0
+ * @version 1.0.0
+ *
+ * @var string
+ */
+const DWS_TPL_OPTION_REVISION = 'dws_tpl_revision';
+
/**
* The value each option reads as while nothing valid is stored.
*
diff --git a/src/Plugin.php b/src/Plugin.php
index d82d9c6..79d0ef4 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -186,7 +186,7 @@ protected function compose(): bool {
return false;
}
- $this->migrations = new MigrationRunner( DWS_TPL_FILE, self::PREFIX . '_revision', dws_tpl_get_migration_steps(), $this->logger, $this->scheduler );
+ $this->migrations = new MigrationRunner( DWS_TPL_FILE, DWS_TPL_OPTION_REVISION, dws_tpl_get_migration_steps(), $this->logger, $this->scheduler );
$this->migrations->register_hooks();
$this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() );
diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php
index 82a5590..53ffb8a 100644
--- a/tests/Integration/MigrationsTest.php
+++ b/tests/Integration/MigrationsTest.php
@@ -2,7 +2,6 @@
namespace DeepWebSolutions\PluginTemplate\Tests\Integration;
-use DeepWebSolutions\PluginTemplate\Plugin;
use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
use PHPUnit\Framework\Attributes\After;
use PHPUnit\Framework\Attributes\CoversNothing;
@@ -28,7 +27,7 @@ protected function restore_rows(): void {
false === $value ? delete_option( $key ) : update_option( $key, $value );
}
- update_option( Plugin::PREFIX . '_revision', \count( dws_tpl_get_migration_steps() ) );
+ update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) );
}
// The runner's time budget lasts for its request, and this process's bootstrap spends it when it migrates a new site, so the row runs as a request of its own.
@@ -38,11 +37,11 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra
foreach ( self::KEYS as $key ) {
delete_option( $key );
}
- update_option( Plugin::PREFIX . '_revision', 0 );
+ update_option( DWS_TPL_OPTION_REVISION, 0 );
dws_tpl_plugin()->migrations?->run();
- self::assertSame( 1, MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) );
+ self::assertSame( 1, MigrationRunner::get_revision( DWS_TPL_OPTION_REVISION ) );
self::assertSame( 'no', get_option( DWS_TPL_OPTION_FEATURE ) );
self::assertSame( '1', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) );
self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) );
diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php
index a272955..5fb95ea 100644
--- a/tests/Integration/PluginBootTest.php
+++ b/tests/Integration/PluginBootTest.php
@@ -71,7 +71,7 @@ public function test_the_debug_constant_logs_every_level(): void {
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( Plugin::PREFIX . '_revision' ) );
+ self::assertSame( \count( dws_tpl_get_migration_steps() ), MigrationRunner::get_revision( DWS_TPL_OPTION_REVISION ) );
}
protected static function get_threshold( WooCommerceLogger $logger ): mixed {
diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php
index f7ab8e2..d00be34 100644
--- a/tests/Integration/UninstallTest.php
+++ b/tests/Integration/UninstallTest.php
@@ -79,7 +79,7 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an
\define( 'WP_UNINSTALL_PLUGIN', plugin_basename( DWS_TPL_FILE ) );
update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' );
- $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . Plugin::PREFIX . "_revision'";
+ $failing = "DELETE FROM `$wpdb->options` WHERE `option_name` = '" . DWS_TPL_OPTION_REVISION . "'";
add_filter( 'query', static fn ( string $query ): string => \str_contains( $query, $failing ) ? 'SELECT * FROM dws_tpl_missing_table' : $query );
add_filter( 'wp_die_handler', static fn (): \Closure => static fn ( string $message, string $title, array $args ): never => throw new \LogicException( $args['response'] . ' ' . $message ) );
$wpdb->suppress_errors();
@@ -88,12 +88,12 @@ public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_an
include \dirname( DWS_TPL_FILE ) . '/uninstall.php';
self::fail( 'The uninstall finished.' );
} catch ( \LogicException $died ) {
- self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . Plugin::PREFIX . '_revision', $died->getMessage() );
+ self::assertStringStartsWith( '500 Uninstall could not remove everything: option ' . DWS_TPL_OPTION_REVISION, $died->getMessage() );
self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) );
} finally {
remove_all_filters( 'query' );
delete_option( DWS_TPL_OPTION_DELETE_DATA );
- delete_option( Plugin::PREFIX . '_revision' );
+ delete_option( DWS_TPL_OPTION_REVISION );
dws_tpl_plugin()->migrations?->run();
}
}
diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php
index 2b8434d..12b1f9b 100644
--- a/tests/Unit/FootprintTest.php
+++ b/tests/Unit/FootprintTest.php
@@ -52,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/migration/run', dws_tpl_get_footprint()['cron_hooks'] );
+ self::assertContains( DWS_TPL_OPTION_REVISION . '/migration/run', dws_tpl_get_footprint()['cron_hooks'] );
self::assertContains( Plugin::PREFIX, dws_tpl_get_footprint()['as_groups'] );
}
From da4cf47d6c4b6a0a057ee296a954ff4d31e1577d Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:57:18 +0100
Subject: [PATCH 11/16] Name the greeting demo after its concept
The demo lived in src/Feature/ as Feature and FeatureDetail, a folder
named after the one class it held and two names that say nothing of
what they do, behind a toggle named DWS_TPL_OPTION_FEATURE; every
plugin built from the template inherited the names.
The demo lives in src/Greeting/: GreetingFeature is its composite and
AccountDashboardGreeting its leaf, named for the surface it greets on,
and the toggle is DWS_TPL_OPTION_GREETING_ENABLED, whose key,
dws_tpl_greeting_enabled, is written as its literal (X ruling 203's
A263). The tests, AGENTS.md's layout, the scaffold README's removal row
and the Without WooCommerce recipe follow.
Assisted-by: Claude Code:claude-opus-5-5
---
AGENTS.md | 2 +-
README.scaffold.md | 2 +-
docs/recipes.md | 2 +-
includes/migrations.php | 6 +++---
includes/options.php | 14 ++++++-------
.../AccountDashboardGreeting.php} | 4 ++--
.../GreetingFeature.php} | 8 ++++----
src/{Feature => Greeting}/index.php | 0
src/Plugin.php | 2 +-
src/Settings.php | 4 ++--
.../AccountDashboardGreetingTest.php} | 20 +++++++++----------
.../GreetingFeatureTest.php} | 18 ++++++++---------
tests/Integration/MigrationsTest.php | 10 +++++-----
tests/Integration/OptionsTest.php | 2 +-
tests/Integration/PluginBootTest.php | 4 ++--
tests/Integration/SettingsTest.php | 12 +++++------
16 files changed, 55 insertions(+), 55 deletions(-)
rename src/{Feature/FeatureDetail.php => Greeting/AccountDashboardGreeting.php} (94%)
rename src/{Feature/Feature.php => Greeting/GreetingFeature.php} (76%)
rename src/{Feature => Greeting}/index.php (100%)
rename tests/Integration/{Feature/FeatureDetailTest.php => Greeting/AccountDashboardGreetingTest.php} (91%)
rename tests/Integration/{Feature/FeatureTest.php => Greeting/GreetingFeatureTest.php} (55%)
diff --git a/AGENTS.md b/AGENTS.md
index 333ee99..91337d1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -12,7 +12,7 @@ src/ the composition root and its components only
├── Plugin.php SLUG, PREFIX (dws_tpl), COMPONENTS, the logger, the scheduler, the migration runner and the kernel
├── Settings.php the WooCommerce settings tab dws_tpl: the greeting's fields and the uninstall consent
├── Blocks.php the blocks from blocks/build/blocks-manifest.php and the block-editor script
-└── Feature/ the greeting: a composite gated on DWS_TPL_OPTION_FEATURE and its account-dashboard child
+└── Greeting/ the greeting: GreetingFeature, gated on DWS_TPL_OPTION_GREETING_ENABLED, and its AccountDashboardGreeting child
includes/ hook-free code as dws_tpl_ functions and DWS_TPL_ constants, loaded in name order, _-prefixed files skipped
├── safeguards.php restrictions that hold whenever the plugin cannot boot; loaded by the entry before the floor check
├── options.php the option keys, their defaults, the typed readers, the translated text and the validator
diff --git a/README.scaffold.md b/README.scaffold.md
index d071aae..a0ddf4f 100644
--- a/README.scaffold.md
+++ b/README.scaffold.md
@@ -53,7 +53,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas
| Part | Where it lives | Removing it |
| --- | --- | --- |
-| The greeting | `src/Feature/`, the Greeting section of `src/Settings.php`, and the `DWS_TPL_OPTION_FEATURE`, `DWS_TPL_OPTION_GREETING` and `DWS_TPL_OPTION_GREETING_ORDERS` keys in `includes/options.php` | Delete `src/Feature/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, text and validator, their lines in `dws_tpl_add_settings()`, and `tests/Integration/Feature/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. |
+| The greeting | `src/Greeting/`, the Greeting section of `src/Settings.php`, and the `DWS_TPL_OPTION_GREETING_ENABLED`, `DWS_TPL_OPTION_GREETING` and `DWS_TPL_OPTION_GREETING_ORDERS` keys in `includes/options.php` | Delete `src/Greeting/` and its row in `Plugin::COMPONENTS`, the Greeting section of `src/Settings.php`, `assets/src/greeting.scss` and its `greeting=` entry in `package.json`'s `build:assets` and `start:assets`, the three keys with their defaults, text and validator, their lines in `dws_tpl_add_settings()`, and `tests/Integration/Greeting/`, then the rows naming them in `OptionsTest`, `MigrationsTest` and `SettingsTest`, and `PluginBootTest`'s expectation that the greeting is skipped. `tests/EndToEnd/settings.spec.ts` drives the greeting's validated field, so point it at a validated field of the plugin's own. |
| The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` and rendered by its `render.php` | Delete `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, `BlocksTest`'s block rows, and the editor script's command, which inserts the block, with `tests/EndToEnd/editor.spec.ts`. |
| The editor script | `assets/src/editor.ts`, a command in the editor's command palette that inserts the example block, enqueued in the block editor by `src/Blocks.php` | Delete it, its `editor=` entry in `build:assets` and `start:assets`, `@wordpress/commands` and `@wordpress/data` from `package.json`'s `devDependencies`, `enqueue_editor_script()` and its hook in `src/Blocks.php`, `BlocksTest`'s editor row and `tests/EndToEnd/editor.spec.ts`. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS` and `BlocksTest`. |
| `models/` and `templates/` | Empty extension points, for classes on Composer's classmap and for template partials | Delete the folders the plugin never fills, and `models/` from `composer.json`'s `autoload.classmap` and `phpstan.dist.neon`'s `paths`, since Composer and PHPStan both fail on a missing folder. |
diff --git a/docs/recipes.md b/docs/recipes.md
index c91c8ac..97cd509 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -1029,7 +1029,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra
}
```
-6. Delete the greeting: `src/Feature/`, `tests/Integration/Feature/` and `assets/src/greeting.scss`, then its entries in `package.json`:
+6. Delete the greeting: `src/Greeting/`, `tests/Integration/Greeting/` and `assets/src/greeting.scss`, then its entries in `package.json`:
```json
"build:assets": "wp-scripts build editor=./assets/src/editor.ts --output-path=assets/build",
diff --git a/includes/migrations.php b/includes/migrations.php
index 99b88e7..6c96580 100644
--- a/includes/migrations.php
+++ b/includes/migrations.php
@@ -36,9 +36,9 @@ function dws_tpl_get_migration_steps(): array {
*/
function dws_tpl_add_settings(): void {
$defaults = array(
- DWS_TPL_OPTION_FEATURE => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_FEATURE ] ),
- DWS_TPL_OPTION_GREETING_ORDERS => (string) DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ],
- DWS_TPL_OPTION_DELETE_DATA => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ] ),
+ DWS_TPL_OPTION_GREETING_ENABLED => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ENABLED ] ),
+ DWS_TPL_OPTION_GREETING_ORDERS => (string) DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ],
+ DWS_TPL_OPTION_DELETE_DATA => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_DELETE_DATA ] ),
);
foreach ( $defaults as $key => $value ) {
diff --git a/includes/options.php b/includes/options.php
index 0dc2845..782d7b8 100644
--- a/includes/options.php
+++ b/includes/options.php
@@ -23,24 +23,24 @@
const DWS_TPL_OPTION_DELETE_DATA = Plugin::PREFIX . '_delete_data';
/**
- * The option that holds 'yes' where the site greets returning customers on their account dashboard.
+ * The option that holds the greeting.
*
* @since 1.0.0
* @version 1.0.0
*
* @var string
*/
-const DWS_TPL_OPTION_FEATURE = Plugin::PREFIX . '_feature';
+const DWS_TPL_OPTION_GREETING = Plugin::PREFIX . '_greeting';
/**
- * The option that holds the greeting.
+ * The option that holds 'yes' where the site greets returning customers on their account dashboard.
*
* @since 1.0.0
* @version 1.0.0
*
* @var string
*/
-const DWS_TPL_OPTION_GREETING = Plugin::PREFIX . '_greeting';
+const DWS_TPL_OPTION_GREETING_ENABLED = 'dws_tpl_greeting_enabled';
/**
* The option that holds the number of orders a customer places before the greeting shows.
@@ -71,9 +71,9 @@
* @var array
*/
const DWS_TPL_OPTION_DEFAULTS = array(
- DWS_TPL_OPTION_DELETE_DATA => false,
- DWS_TPL_OPTION_FEATURE => false,
- DWS_TPL_OPTION_GREETING_ORDERS => 1,
+ DWS_TPL_OPTION_DELETE_DATA => false,
+ DWS_TPL_OPTION_GREETING_ENABLED => false,
+ DWS_TPL_OPTION_GREETING_ORDERS => 1,
);
/**
diff --git a/src/Feature/FeatureDetail.php b/src/Greeting/AccountDashboardGreeting.php
similarity index 94%
rename from src/Feature/FeatureDetail.php
rename to src/Greeting/AccountDashboardGreeting.php
index 5e3bc93..685f6d0 100644
--- a/src/Feature/FeatureDetail.php
+++ b/src/Greeting/AccountDashboardGreeting.php
@@ -1,6 +1,6 @@
'checkbox',
- 'id' => DWS_TPL_OPTION_FEATURE,
+ 'id' => DWS_TPL_OPTION_GREETING_ENABLED,
'title' => __( 'Show the greeting', 'dws-plugin-template' ),
'desc' => __( 'Greet returning customers', 'dws-plugin-template' ),
- 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_FEATURE ] ),
+ 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ENABLED ] ),
),
array(
'type' => 'text',
diff --git a/tests/Integration/Feature/FeatureDetailTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php
similarity index 91%
rename from tests/Integration/Feature/FeatureDetailTest.php
rename to tests/Integration/Greeting/AccountDashboardGreetingTest.php
index eba3841..af1f38e 100644
--- a/tests/Integration/Feature/FeatureDetailTest.php
+++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php
@@ -1,15 +1,15 @@
register_hooks();
self::assertSame( 10, has_action( 'woocommerce_account_dashboard', array( $detail, 'print_greeting' ) ) );
@@ -77,12 +77,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 FeatureDetail() )->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 FeatureDetail() )->enqueue_style();
+ ( new AccountDashboardGreeting() )->enqueue_style();
} finally {
remove_filter( 'woocommerce_is_account_page', '__return_true' );
}
@@ -97,7 +97,7 @@ public function test_the_greeting_style_skips_the_account_page_endpoints(): void
add_filter( 'woocommerce_is_account_page', '__return_true' );
$wp->query_vars['orders'] = '';
try {
- new FeatureDetail()->enqueue_style();
+ new AccountDashboardGreeting()->enqueue_style();
} finally {
unset( $wp->query_vars['orders'] );
remove_filter( 'woocommerce_is_account_page', '__return_true' );
@@ -110,7 +110,7 @@ public function test_the_greeting_style_skips_logged_out_visitors(): void {
wp_set_current_user( 0 );
add_filter( 'woocommerce_is_account_page', '__return_true' );
try {
- new FeatureDetail()->enqueue_style();
+ new AccountDashboardGreeting()->enqueue_style();
} finally {
remove_filter( 'woocommerce_is_account_page', '__return_true' );
}
@@ -122,7 +122,7 @@ public function test_the_greeting_style_enqueues_only_a_stylesheet_it_registered
wp_register_style( 'dws-plugin-template-greeting', 'https://example.org/other.css', array(), '1' );
add_filter( 'woocommerce_is_account_page', '__return_true' );
try {
- new FeatureDetail()->enqueue_style();
+ new AccountDashboardGreeting()->enqueue_style();
} finally {
remove_filter( 'woocommerce_is_account_page', '__return_true' );
}
@@ -186,7 +186,7 @@ protected function place_orders( int $count, string $status = 'completed' ): voi
protected function print(): string {
\ob_start();
- ( new FeatureDetail() )->print_greeting();
+ ( new AccountDashboardGreeting() )->print_greeting();
return (string) \ob_get_clean();
}
diff --git a/tests/Integration/Feature/FeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php
similarity index 55%
rename from tests/Integration/Feature/FeatureTest.php
rename to tests/Integration/Greeting/GreetingFeatureTest.php
index e0036b7..94a28a8 100644
--- a/tests/Integration/Feature/FeatureTest.php
+++ b/tests/Integration/Greeting/GreetingFeatureTest.php
@@ -1,8 +1,8 @@
stored = get_option( DWS_TPL_OPTION_FEATURE );
+ $this->stored = get_option( DWS_TPL_OPTION_GREETING_ENABLED );
}
#[After]
protected function restore_row(): void {
- false === $this->stored ? delete_option( DWS_TPL_OPTION_FEATURE ) : update_option( DWS_TPL_OPTION_FEATURE, $this->stored );
+ 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_feature_loads_only_where_the_greeting_is_switched_on( ?string $stored, bool $expected ): void {
- \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_FEATURE ) : update_option( DWS_TPL_OPTION_FEATURE, $stored );
+ 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 );
- self::assertSame( $expected, Feature::should_load() );
+ self::assertSame( $expected, GreetingFeature::should_load() );
}
#[RunInSeparateProcess]
public function test_with_the_greeting_switched_on_every_declared_component_boots(): void {
- update_option( DWS_TPL_OPTION_FEATURE, 'yes' );
+ update_option( DWS_TPL_OPTION_GREETING_ENABLED, 'yes' );
$plugin = new Plugin();
$plugin->boot();
diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php
index 53ffb8a..384cd32 100644
--- a/tests/Integration/MigrationsTest.php
+++ b/tests/Integration/MigrationsTest.php
@@ -10,7 +10,7 @@
#[CoversNothing]
final class MigrationsTest extends TestCase {
- protected const array KEYS = array( DWS_TPL_OPTION_FEATURE, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA );
+ protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA );
protected array $stored = array();
@@ -42,22 +42,22 @@ public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untra
dws_tpl_plugin()->migrations?->run();
self::assertSame( 1, MigrationRunner::get_revision( DWS_TPL_OPTION_REVISION ) );
- self::assertSame( 'no', get_option( DWS_TPL_OPTION_FEATURE ) );
+ self::assertSame( 'no', get_option( DWS_TPL_OPTION_GREETING_ENABLED ) );
self::assertSame( '1', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) );
self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) );
self::assertFalse( get_option( DWS_TPL_OPTION_GREETING ) );
- foreach ( array( DWS_TPL_OPTION_FEATURE, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ) as $key ) {
+ foreach ( array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ) as $key ) {
self::assertSame( 'on', $wpdb->get_var( $wpdb->prepare( "SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", $key ) ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- Reads the stored autoload flag.
}
}
public function test_the_first_step_keeps_the_values_a_site_stored(): void {
- update_option( DWS_TPL_OPTION_FEATURE, 'yes' );
+ update_option( DWS_TPL_OPTION_GREETING_ENABLED, 'yes' );
update_option( DWS_TPL_OPTION_GREETING_ORDERS, '5' );
dws_tpl_add_settings();
- self::assertSame( 'yes', get_option( DWS_TPL_OPTION_FEATURE ) );
+ self::assertSame( 'yes', get_option( DWS_TPL_OPTION_GREETING_ENABLED ) );
self::assertSame( '5', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) );
}
diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php
index 438a245..28ea271 100644
--- a/tests/Integration/OptionsTest.php
+++ b/tests/Integration/OptionsTest.php
@@ -133,7 +133,7 @@ public function test_a_string_that_is_not_a_string_reads_its_translated_default(
}
public function test_an_option_without_a_translated_default_has_no_text(): void {
- self::assertSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_FEATURE ) );
+ self::assertSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING_ENABLED ) );
}
#[DataProvider( 'submitted_order_counts' )]
diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php
index 5fb95ea..9755c4c 100644
--- a/tests/Integration/PluginBootTest.php
+++ b/tests/Integration/PluginBootTest.php
@@ -2,7 +2,7 @@
namespace DeepWebSolutions\PluginTemplate\Tests\Integration;
-use DeepWebSolutions\PluginTemplate\Feature\Feature;
+use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature;
use DeepWebSolutions\PluginTemplate\Plugin;
use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner;
use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Logging\WooCommerceLogger;
@@ -19,7 +19,7 @@ public function test_the_plugin_boots_with_the_greeting_gated_off_until_a_site_s
$plugin = dws_tpl_plugin();
self::assertSame( 'booted', $plugin->boot->status );
- self::assertSame( array( Feature::class ), $plugin->kernel?->skipped );
+ self::assertSame( array( GreetingFeature::class ), $plugin->kernel?->skipped );
}
public function test_the_entry_loads_every_include_but_those_whose_names_start_with_an_underscore(): void {
diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php
index a018c75..47fde31 100644
--- a/tests/Integration/SettingsTest.php
+++ b/tests/Integration/SettingsTest.php
@@ -12,7 +12,7 @@
#[CoversNothing]
final class SettingsTest extends TestCase {
- protected const array KEYS = array( DWS_TPL_OPTION_FEATURE, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA );
+ protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA );
protected static SettingsTab $tab;
@@ -75,7 +75,7 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau
$form = $this->render();
self::assertSame( 'DWS Plugin Template', self::$tab->get_label() );
- self::assertFalse( $form->getElementById( DWS_TPL_OPTION_FEATURE )?->hasAttribute( 'checked' ) );
+ self::assertFalse( $form->getElementById( DWS_TPL_OPTION_GREETING_ENABLED )?->hasAttribute( 'checked' ) );
self::assertSame( '', $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) );
self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'placeholder' ) );
self::assertSame( '1', $form->getElementById( DWS_TPL_OPTION_GREETING_ORDERS )?->getAttribute( 'value' ) );
@@ -86,10 +86,10 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau
public function test_the_tab_words_each_field_by_what_it_does(): void {
$form = $this->render();
$rows = array(
- DWS_TPL_OPTION_FEATURE => array( 'Show the greeting', 'Greet returning customers' ),
- DWS_TPL_OPTION_GREETING => array( 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ),
- DWS_TPL_OPTION_GREETING_ORDERS => array( 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ),
- DWS_TPL_OPTION_DELETE_DATA => array( 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ),
+ DWS_TPL_OPTION_GREETING_ENABLED => array( 'Show the greeting', 'Greet returning customers' ),
+ DWS_TPL_OPTION_GREETING => array( 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ),
+ DWS_TPL_OPTION_GREETING_ORDERS => array( 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ),
+ DWS_TPL_OPTION_DELETE_DATA => array( 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ),
);
foreach ( $rows as $key => $texts ) {
From 8978421d6309b3b477a04af3e5f64d20bfafd5a9 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:57:36 +0100
Subject: [PATCH 12/16] Lower the prefix cap to 22 for the greeting's longest
constant
DWS_TPL_OPTION_GREETING_ENABLED is one character longer than
DWS_TPL_OPTION_GREETING_ORDERS, so with a 23-character segment its
filled-in name pushes includes/migrations.php's array arrows past
column 60, where PHPCS stops aligning them and the plugin's first
composer lint:php fails; at 22 both files pass.
The fill-in accepts a segment of at most 22 characters, its message and
comment say so, the README's naming rule follows, and Quality rehearses
at the new cap with dws_acmeextraordinarysales (X ruling 196's A218).
Assisted-by: Claude Code:claude-opus-5-5
---
.github/scripts/fill-in-scaffold.sh | 4 ++--
.github/workflows/quality.yml | 2 +-
README.md | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh
index aaa7aa8..e3de119 100755
--- a/.github/scripts/fill-in-scaffold.sh
+++ b/.github/scripts/fill-in-scaffold.sh
@@ -22,10 +22,10 @@ done
# A namespace that is a reserved word, such as List, parses from PHP 8.0 only, which the fill-in job's PHP 7.4 syntax
# check refuses.
[[ $NS =~ ^[A-Za-z][A-Za-z0-9]*$ ]] || fail 'php-namespace must match ^[A-Za-z][A-Za-z0-9]*$.'
-# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 23 characters
+# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 22 characters
# the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks,
# and the framework's admin notices refuse a network or hidden segment.
-[[ $PREFIX =~ ^dws_[a-z0-9]{1,23}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden.'
+[[ $PREFIX =~ ^dws_[a-z0-9]{1,22}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,22}$ and must not be dws_network or dws_hidden.'
# The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the
# next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses.
[[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] ||
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index e0d1e6a..39b465c 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -88,7 +88,7 @@ jobs:
SLUG: acme-orders
NS: AcmeOrders
# The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing.
- PREFIX: dws_acmeextraordinaryorders
+ PREFIX: dws_acmeextraordinarysales
PORT: '8821'
REPO: acme/acme-orders
run: .github/scripts/fill-in-scaffold.sh
diff --git a/README.md b/README.md
index 4dc454e..949a2d7 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ The workflow:
## 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 23 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.
From 4dbbef939a20084433d3b4b25d4eb8f5d099c6cc Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:58:06 +0100
Subject: [PATCH 13/16] Pass the consent option to the uninstall engine
directly
uninstall.php copied DWS_TPL_OPTION_DELETE_DATA into a variable inside
its try block and passed the copy, though the constant exists wherever
the call runs: a failed require of includes/options.php returns before
it.
Uninstaller::run() takes the constant itself, and the Without
WooCommerce recipe passes its consent array the same way (X ruling
203's A264).
Assisted-by: Claude Code:claude-opus-5-5
---
docs/recipes.md | 21 +++++++++++----------
uninstall.php | 3 +--
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/docs/recipes.md b/docs/recipes.md
index 97cd509..92692b2 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -1290,21 +1290,22 @@ function dws_tpl_add_settings(): void {
}
```
-10. In `uninstall.php`, delete the `MetaTypes` import, and take consent from the settings row's `delete_data` key:
+10. In `uninstall.php`, delete the `MetaTypes` import and `$dws_tpl_meta_types`, and take consent from the settings row's `delete_data` key:
```php
$dws_tpl_footprint = dws_tpl_get_footprint();
- $dws_tpl_consent = array(
- 'option' => DWS_TPL_OPTION_SETTINGS,
- 'key' => DWS_TPL_SETTING_DELETE_DATA,
- );
// ...
-Uninstaller::run(
- $dws_tpl_file,
- $dws_tpl_footprint,
- array( 'consent' => $dws_tpl_consent )
-);
+ Uninstaller::run(
+ $dws_tpl_file,
+ $dws_tpl_footprint,
+ array(
+ 'consent' => array(
+ 'option' => DWS_TPL_OPTION_SETTINGS,
+ 'key' => DWS_TPL_SETTING_DELETE_DATA,
+ ),
+ )
+ );
```
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`:
diff --git a/uninstall.php b/uninstall.php
index a470ee4..a537cdb 100644
--- a/uninstall.php
+++ b/uninstall.php
@@ -30,7 +30,6 @@
require_once __DIR__ . '/includes/footprint.php';
require_once __DIR__ . '/includes/options.php';
$dws_tpl_footprint = dws_tpl_get_footprint();
- $dws_tpl_consent = DWS_TPL_OPTION_DELETE_DATA;
$dws_tpl_meta_types = MetaTypes::DEFINITIONS;
if ( ! class_exists( Uninstaller::class ) ) {
throw new RuntimeException( 'its uninstall engine is missing.' );
@@ -50,7 +49,7 @@
$dws_tpl_file,
$dws_tpl_footprint,
array(
- 'consent' => array( 'option' => $dws_tpl_consent ),
+ 'consent' => array( 'option' => DWS_TPL_OPTION_DELETE_DATA ),
'meta_types' => $dws_tpl_meta_types,
)
);
From 1f63b48f5381e9c468770ef79f212d107727fe48 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:58:41 +0100
Subject: [PATCH 14/16] Build the asset path in one call and type the script's
arguments apart
dws_tpl_get_asset() built the .asset.php path from basename() of the
path less an extension pathinfo() had read, three calls where
pathinfo()'s filename is one, and dws_tpl_register_script() padded its
three @param lines to a 90-column array shape.
The path reads pathinfo( $path, PATHINFO_FILENAME ), and $args is an
array whose shape a @phpstan-param carries, which PHPStan still checks
at every call (X ruling 203's A265).
Assisted-by: Claude Code:claude-opus-5-5
---
includes/assets.php | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/includes/assets.php b/includes/assets.php
index 5dd0fb0..d17176a 100644
--- a/includes/assets.php
+++ b/includes/assets.php
@@ -28,7 +28,7 @@ function dws_tpl_get_asset( string $path ): array {
*
* @var array{dependencies: list, version: string, type?: string, handle?: string} $asset
*/
- $asset = require plugin_dir_path( DWS_TPL_FILE ) . dirname( $path ) . '/' . basename( $path, '.' . pathinfo( $path, PATHINFO_EXTENSION ) ) . '.asset.php';
+ $asset = require plugin_dir_path( DWS_TPL_FILE ) . dirname( $path ) . '/' . pathinfo( $path, PATHINFO_FILENAME ) . '.asset.php';
return $asset;
}
@@ -39,9 +39,11 @@ function dws_tpl_get_asset( string $path ): array {
* @since 1.0.0
* @version 1.0.0
*
- * @param non-empty-string $handle The script's handle.
- * @param non-empty-string $path The built script's path, relative to the plugin's directory.
- * @param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args Optional. How the script loads. Default none, which loads it in the head.
+ * @phpstan-param array{strategy?: 'async'|'defer', in_footer?: bool, fetchpriority?: 'auto'|'low'|'high'} $args
+ *
+ * @param non-empty-string $handle The script's handle.
+ * @param non-empty-string $path The built script's path, relative to the plugin's directory.
+ * @param array $args Optional. How the script loads. Default none, which loads it in the head.
*
* @return bool
*/
From 6f626202df33597c6878e0b5e39237ef25364904 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 19:59:17 +0100
Subject: [PATCH 15/16] Name the boot boundary for its role
Plugin exposed a $boot property beside a boot() method, so
$this->boot->run() inside boot() and ->boot->status beside ->boot()
read alike, and a reader had to check the parentheses to tell the
boundary from the action.
The property is $boundary, the diagnostic reads
dws_tpl_plugin()->boundary->status, and the boot rows, the
without-WooCommerce spec and the Without WooCommerce recipe follow, as
the framework's fixtures do (X ruling 203's A266).
Assisted-by: Claude Code:claude-opus-5-5
---
docs/recipes.md | 2 +-
src/Plugin.php | 6 +++---
tests/EndToEnd/without-woocommerce.spec.ts | 2 +-
tests/Integration/Greeting/GreetingFeatureTest.php | 2 +-
tests/Integration/PluginBootTest.php | 2 +-
5 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/docs/recipes.md b/docs/recipes.md
index 92692b2..6961739 100644
--- a/docs/recipes.md
+++ b/docs/recipes.md
@@ -1004,7 +1004,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra
public function __construct() {
$this->logger = new ErrorLogLogger( self::SLUG );
$this->scheduler = new Scheduler( self::PREFIX );
- $this->boot = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger );
}
// ...
diff --git a/src/Plugin.php b/src/Plugin.php
index 684ffad..f707700 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -68,7 +68,7 @@ final class Plugin {
*
* @var PluginBoot
*/
- public readonly PluginBoot $boot;
+ public readonly PluginBoot $boundary;
/**
* The scheduler, which registers its WP-Cron schedules even while the plugin is blocked.
@@ -123,7 +123,7 @@ final class Plugin {
public function __construct() {
$this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_TPL_DEBUG' ) && true === \constant( 'DWS_TPL_DEBUG' ) ? 'debug' : null );
$this->scheduler = new Scheduler( self::PREFIX );
- $this->boot = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger );
+ $this->boundary = new PluginBoot( DWS_TPL_FILE, self::PREFIX, $this->logger );
}
// endregion
@@ -139,7 +139,7 @@ public function __construct() {
* @return void
*/
public function boot(): void {
- $this->boot->run( $this->compose( ... ) );
+ $this->boundary->run( $this->compose( ... ) );
}
/**
diff --git a/tests/EndToEnd/without-woocommerce.spec.ts b/tests/EndToEnd/without-woocommerce.spec.ts
index bbdb120..b557705 100644
--- a/tests/EndToEnd/without-woocommerce.spec.ts
+++ b/tests/EndToEnd/without-woocommerce.spec.ts
@@ -17,7 +17,7 @@ const bootStatus = (): string =>
'cli',
'wp',
'eval',
- 'echo dws_tpl_plugin()->boot->status;',
+ 'echo dws_tpl_plugin()->boundary->status;',
],
{ encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] }
)
diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php
index 94a28a8..424e6c0 100644
--- a/tests/Integration/Greeting/GreetingFeatureTest.php
+++ b/tests/Integration/Greeting/GreetingFeatureTest.php
@@ -45,7 +45,7 @@ public function test_with_the_greeting_switched_on_every_declared_component_boot
$plugin = new Plugin();
$plugin->boot();
- self::assertSame( 'booted', $plugin->boot->status );
+ self::assertSame( 'booted', $plugin->boundary->status );
self::assertSame( array(), $plugin->kernel?->skipped );
}
}
diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php
index 9755c4c..36283b8 100644
--- a/tests/Integration/PluginBootTest.php
+++ b/tests/Integration/PluginBootTest.php
@@ -18,7 +18,7 @@ final class PluginBootTest extends TestCase {
public function test_the_plugin_boots_with_the_greeting_gated_off_until_a_site_switches_it_on(): void {
$plugin = dws_tpl_plugin();
- self::assertSame( 'booted', $plugin->boot->status );
+ self::assertSame( 'booted', $plugin->boundary->status );
self::assertSame( array( GreetingFeature::class ), $plugin->kernel?->skipped );
}
From c608b5a380f17ff66a9ced0fa1a16a3196f22402 Mon Sep 17 00:00:00 2001
From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com>
Date: Tue, 10 Nov 2026 20:03:14 +0100
Subject: [PATCH 16/16] Key the settings wording rows by field name
The wording row held its fields in an array keyed by the option
constants three tabs deep, so a plugin filled in at the prefix cap,
whose constants are 22 characters longer, pushed the arrows past column
60, where PHPCS stops aligning them, and its first composer lint:php
failed in Quality's rehearsal.
The fields come from a data provider keyed toggle, message, threshold
and consent, one row each.
Assisted-by: Claude Code:claude-opus-5-5
---
tests/Integration/SettingsTest.php | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php
index 47fde31..e7739c3 100644
--- a/tests/Integration/SettingsTest.php
+++ b/tests/Integration/SettingsTest.php
@@ -42,6 +42,15 @@ public static function invalid_order_counts(): array {
);
}
+ public static function field_texts(): array {
+ return array(
+ 'toggle' => array( DWS_TPL_OPTION_GREETING_ENABLED, 'Show the greeting', 'Greet returning customers' ),
+ 'message' => array( DWS_TPL_OPTION_GREETING, 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ),
+ 'threshold' => array( DWS_TPL_OPTION_GREETING_ORDERS, 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ),
+ 'consent' => array( DWS_TPL_OPTION_DELETE_DATA, 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ),
+ );
+ }
+
public static function whole_order_counts(): array {
return array(
'padded' => array( ' 3 ' ),
@@ -83,21 +92,12 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau
self::assertFalse( $form->getElementById( DWS_TPL_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) );
}
- public function test_the_tab_words_each_field_by_what_it_does(): void {
- $form = $this->render();
- $rows = array(
- DWS_TPL_OPTION_GREETING_ENABLED => array( 'Show the greeting', 'Greet returning customers' ),
- DWS_TPL_OPTION_GREETING => array( 'Message', 'Leave empty to show the default greeting in each customer\'s language.' ),
- DWS_TPL_OPTION_GREETING_ORDERS => array( 'Orders before the greeting', 'Customers see the greeting once this many of their orders are paid.' ),
- DWS_TPL_OPTION_DELETE_DATA => array( 'Remove all data on uninstallation', 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later' ),
- );
+ #[DataProvider( 'field_texts' )]
+ public function test_the_tab_words_each_field_by_what_it_does( string $key, string $title, string $description ): void {
+ $row = (string) $this->render()->getElementById( $key )?->closest( 'tr' )?->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
- foreach ( $rows as $key => $texts ) {
- $row = (string) $form->getElementById( $key )?->closest( 'tr' )?->textContent; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- A DOM property.
- foreach ( $texts as $text ) {
- self::assertStringContainsString( $text, $row, $key );
- }
- }
+ self::assertStringContainsString( $title, $row );
+ self::assertStringContainsString( $description, $row );
}
public function test_a_save_that_leaves_the_message_untouched_stores_no_translated_text(): void {