Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions .github/scripts/fill-in-scaffold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 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,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.'
# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; the framework's
# identity takes a prefix of at most 32 characters, which Quality's rehearsal at the cap checks, and its admin notices
# refuse a network or hidden segment.
[[ $PREFIX =~ ^dws_[a-z0-9]{1,28}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,28}$ 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 ) ]] ||
Expand All @@ -38,6 +38,7 @@ name_breakers="['\\\$/<>]"
[[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.'

export SCREAMING_PREFIX="${PREFIX^^}"
export HOOK_ROOT="${SLUG//-/_}"
export TESTS_PORT=$(( PORT + 1 ))
export MULTISITE_PORT=$(( PORT + 2 ))
export REPO_NAME="${REPO#*/}"
Expand All @@ -50,8 +51,8 @@ git mv --force README.scaffold.md README.md
# The scaffold README becomes the plugin's README.md, which .distignore already excludes.
perl -ni -e 'print unless /^README\.scaffold\.md$/' .distignore
git rm --quiet includes/_disabled-example.php
# The template's recipe test loads the recipe's safeguard helper, which a plugin that applies the recipe declares
# itself, so only the recipe's own test stays.
# The template's recipe test loads the recipe's SafeguardsTest, which a plugin that applies the recipe declares
# itself, so only the plugin's own copy stays.
git rm --quiet tests/Integration/SafeguardRecipeTest.php
git mv dws-plugin-template.php "$SLUG.php"

Expand All @@ -63,6 +64,7 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.
s{ahegyes/wordpress-plugin-template}{$ENV{REPO}}g;
s/^# wordpress-plugin-template$/# $ENV{REPO_NAME}/;
s/dws-plugin-template/$ENV{SLUG}/g;
s/dws_plugin_template/$ENV{HOOK_ROOT}/g;
s/dws_tpl/$ENV{PREFIX}/g;
s/DWS_TPL/$ENV{SCREAMING_PREFIX}/g;
s/PluginTemplate/$ENV{NS}/g;
Expand Down Expand Up @@ -104,7 +106,7 @@ npm ci --ignore-scripts --no-audit --no-fund
node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/EndToEnd}/**/*.{js,jsx,ts,tsx}'

status=0
if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plugin Template|wordpress-plugin-template' -- . ':!.github/scripts/fill-in-scaffold.sh'; then
if git grep -n -I -E 'dws-plugin-template|dws_plugin_template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plugin Template|wordpress-plugin-template' -- . ':!.github/scripts/fill-in-scaffold.sh'; then
echo "::error::The fill-in left the template's names above."
status=1
fi
Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/fill-in-scaffold.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,15 @@ on:
required: true
type: string
plugin-slug:
description: 'Kebab-case slug, also the text domain and the entry file name, such as "acme-orders"'
description: 'Kebab-case slug, also the text domain, the hook root and the entry file name, such as "acme-orders"'
required: true
type: string
php-namespace:
description: 'PHP namespace segment after DeepWebSolutions\, such as "AcmeOrders"'
required: true
type: string
prefix:
description: 'Prefix of the global functions, constants, options and hooks, dws_ plus one segment, such as "dws_ao"'
description: 'Prefix of the global functions, constants and stored data, dws_ plus one segment of up to 28 lowercase letters and digits, such as "dws_ao"'
required: true
type: string
wp-env-port:
Expand Down Expand Up @@ -76,7 +76,7 @@ jobs:
env:
SLUG: ${{ inputs.plugin-slug }}
run: |
for file in "$SLUG.php" uninstall.php includes/*.php; do
for file in "$SLUG.php" uninstall.php Safeguards.php; do
php -l "$file"
done

Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@b107001cce9f2960495d9d50481c8540d228f60c
with:
php-versions: '["7.4"]'
paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }}
paths: ${{ format('["{0}.php", "uninstall.php", "Safeguards.php"]', needs.slug.outputs.slug) }}

scaffold:
name: Scaffold rehearsal
Expand Down Expand Up @@ -87,8 +87,8 @@ jobs:
DESCRIPTION: 'Acme Orders keeps a store''s "orders" in one place, C:\ included.'
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_acmeextraordinarysales
# The rehearsal fills in the longest prefix the script accepts, so its Unit run fails once the framework refuses it.
PREFIX: dws_acmeextraordinarysalesorders
PORT: '8831'
REPO: acme/acme-orders
run: .github/scripts/fill-in-scaffold.sh
Expand All @@ -112,6 +112,6 @@ jobs:
tools: none
- name: Check the PHP 7.4 syntax
run: |
for file in acme-orders.php uninstall.php includes/*.php; do
for file in acme-orders.php uninstall.php Safeguards.php; do
php -l "$file"
done
35 changes: 17 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,19 @@ A WordPress plugin built on the five wp-framework packages (`ahegyes/wp-framewor
## Layout

```
dws-plugin-template.php the entry: safeguards, bootstrap load.php, the updater, the floor check, the build gate, the autoloader
functions.php dws_tpl_plugin(), the boot on plugins_loaded 15, deactivation, the HPOS declaration (false), the includes/ loader
uninstall.php the framework's uninstall engine over the footprint, with DWS_TPL_OPTION_DELETE_DATA's consent
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
dws-plugin-template.php the entry: the safeguards, then, in one try, bootstrap load.php, the updater, the floor check, the build gate, the autoloader and functions.php, whose catch shows the build notice
Safeguards.php the safeguards, as static closures, which the entry requires before the floor check
functions.php the @api dws_tpl_plugin(), the includes/ loader, then the boot on plugins_loaded 15, the HPOS declaration (false) and deactivation, as static closures
uninstall.php the framework's uninstall engine over Plugin::FOOTPRINT, with Options::DELETE_DATA's consent
src/ the plugin's classes, each named for its concept
├── Plugin.php SLUG, PREFIX (dws_tpl), COMPONENTS, FOOTPRINT, the identity, the logger, the scheduler, the migration runner and the kernel
├── Options.php the option keys, their defaults and the typed readers
├── Migrations.php get_steps() over the protected steps, append-only
├── Settings.php the WooCommerce settings tab dws_tpl: the greeting's fields, their validator and the uninstall consent
├── Blocks.php the blocks from blocks/build/blocks-manifest.php and the block-editor script
└── 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
├── footprint.php dws_tpl_get_footprint(), every kind of data the plugin stores
├── migrations.php dws_tpl_get_migration_steps() and one function per step, append-only
├── assets.php the *.asset.php reader and the script and style registrars
└── Greeting/ the greeting: GreetingFeature, gated on Options::GREETING_ENABLED, and its AccountDashboardGreeting child, which owns the default message
includes/ more @api functions and constants, grouped by topic, loaded in name order, _-prefixed files skipped
├── greeting.php whether the greeting is on, for an integration that needs the setting
└── metadata.php the slug, name and version getters, the plugin's surface for a peer plugin
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
Expand Down Expand Up @@ -52,13 +51,13 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru

## Conventions

1. **Placement.** `src/` holds only the composition root and its components. Code that registers no hooks, such as keys, readers, the footprint, migration steps and asset registrars, is `dws_tpl_` functions and `DWS_TPL_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`.
1. **Placement.** The global namespace holds only the plugin's contract: `functions.php` and `includes/` declare its `@api` functions and constants, and `functions.php` registers its hooks as static closures. Every other symbol is a member of a final class in `src/` named for its concept, `protected` wherever only its class uses it: the composition root, which holds the footprint, a component, `Options`, `Migrations` or another class that registers no hooks. No namespace or folder is named "Internal". Container definitions are composition and stay in `Plugin::get_definitions()`; a component takes the plugin's identity, or a service such as `Assets`, by constructor.
2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`.
3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`.
4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them.
4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, and a setting through an `@api` function where the base plugin offers one, else through the base plugin's options. It calls the base plugin's functions from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them.
5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input.
6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry.
7. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file, and WooCommerce where the plugin requires it, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one function, which the file runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the whole file, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads.
6. **Options and data.** Options are read only through `Options`' typed readers, which translate nothing, safeguards excepted; a translated default belongs to the component that shows it, as the greeting's message does. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta, but for scheduled jobs, whose hooks take the hook root as every hook does and whose Action Scheduler group is the bare `dws_tpl`. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else, such as a scheduled hook, its own entry.
7. **Safeguards** live in `Safeguards.php`, a final class whose `register()` hooks each safeguard as a static closure, which no code can remove by name, so a safeguard relaxes only through its own filter, through the protected `is_relaxed()` (`docs/recipes.md`); only what other classes read is public. The entry requires the class by path before the floor check, so it parses on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one closure, which `register()` runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the class and the entry, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads.
8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails.
9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime.

Expand All @@ -70,7 +69,7 @@ The plugin's contract is the elements tagged `@api`, the hooks it fires, and its

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