From fa5fc7facd27b67e0c810ae51985f377c09638b9 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 19:10:11 +0100 Subject: [PATCH 01/15] Pin the safeguard recipe's relaxation rule A safeguard holds even when the build fails, and a plugin relaxes one only through the safeguard's own filter, with an explicit false, once the plugin has finished booting. The recipe that teaches it is the only copy a plugin starts from, so a wrong helper there spreads to every plugin that has a safeguard. The Integration suite now loads the recipe's includes/safeguards.php block from docs/recipes.md and runs its export lock through map_meta_cap(): no filter, null, 0 and '' keep it locked, false unlocks it, and false before dws_tpl_initialized keeps it locked. The recipe does not exist yet, so the rows fail. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/SafeguardRecipeTest.php | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/Integration/SafeguardRecipeTest.php diff --git a/tests/Integration/SafeguardRecipeTest.php b/tests/Integration/SafeguardRecipeTest.php new file mode 100644 index 0000000..b1038b9 --- /dev/null +++ b/tests/Integration/SafeguardRecipeTest.php @@ -0,0 +1,66 @@ + array( array(), false ), + 'null' => array( array( 'relaxation' => null ), false ), + 'zero' => array( array( 'relaxation' => 0 ), false ), + 'empty-string' => array( array( 'relaxation' => '' ), false ), + 'false' => array( array( 'relaxation' => false ), true ), + ); + } + + #[DataProvider( 'relaxations' )] + public function test_only_an_explicit_false_relaxes_the_safeguard( array $spec, bool $relaxed ): void { + $relaxation = static fn (): mixed => $spec['relaxation']; + if ( \array_key_exists( 'relaxation', $spec ) ) { + add_filter( self::FILTER, $relaxation ); + } + + try { + self::assertSame( $relaxed ? array( 'manage_options' ) : array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); + } finally { + remove_filter( self::FILTER, $relaxation ); + } + } + + public function test_an_explicit_false_does_not_relax_the_safeguard_before_the_plugin_booted(): void { + global $wp_actions; + self::assertGreaterThan( 0, did_action( 'dws_tpl_initialized' ) ); + $fired = $wp_actions['dws_tpl_initialized']; + unset( $wp_actions['dws_tpl_initialized'] ); + add_filter( self::FILTER, '__return_false' ); + + try { + self::assertSame( array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); + } finally { + remove_filter( self::FILTER, '__return_false' ); + $wp_actions['dws_tpl_initialized'] = $fired; + } + } +} From c34b70b8cbebc73537cd6d74579554a55247dee5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 19:46:22 +0100 Subject: [PATCH 02/15] Fill in a plugin's own prefix and ports, and rehearse it on every PR The fill-in could not produce a working plugin. It swept names no file has held since the v2 anatomy, never the dws_tpl and DWS_TPL prefix, so two plugins from the template both declared dws_tpl_plugin() and shared every dws_tpl_* option; it swept one of the two wp-env ports, named .wp-env.belowfloor.json and readme.txt, which are gone, and its check of the first failed the run. It also rewrote three workflows, and GitHub refuses a GITHUB_TOKEN push that changes a workflow, so its push would have failed in every plugin. The sweep is now .github/scripts/fill-in-scaffold.sh, with a required dws_ prefix and port, the tests environment on the next port, the scaffold README in place of the template's, the disabled example include deleted and the lock's content hash refreshed. It never touches .github/workflows/: Quality, Release and Tests read the slug from composer.json's text domain. The script fails when any template name or port is left or a workflow changed. Quality runs it on every pull request in the template with sample inputs, then installs, lints and unit-tests the filled-in plugin. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 77 +++++++++++++ .github/workflows/fill-in-scaffold.yml | 150 +++++-------------------- .github/workflows/quality.yml | 55 ++++++++- .github/workflows/release.yml | 24 +++- .github/workflows/tests.yml | 7 +- README.scaffold.md | 65 +++++++++++ 6 files changed, 254 insertions(+), 124 deletions(-) create mode 100755 .github/scripts/fill-in-scaffold.sh create mode 100644 README.scaffold.md diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh new file mode 100755 index 0000000..8f8dd96 --- /dev/null +++ b/.github/scripts/fill-in-scaffold.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Fills in the template's placeholders with a plugin's identity. fill-in-scaffold.yml runs it once in a plugin +# generated from the template, and Quality rehearses it on the template with sample inputs. +set -euo pipefail + +: "${PLUGIN_NAME:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}" + +# The slug names the entry file and the text domain, so it stays clean kebab-case. +grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' <<< "$SLUG" || { echo "::error::plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen."; exit 1; } +grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$."; exit 1; } +# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's. +grep -Eq '^dws_[a-z0-9]+$' <<< "$PREFIX" || { echo "::error::prefix must match ^dws_[a-z0-9]+$."; exit 1; } +# Both ports land in JSON numbers, where a leading zero is invalid, and the tests environment takes the next one. +{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65534 ]; } || { echo "::error::wp-env-port must be 1-65534 with no leading zero."; exit 1; } +# The display name lands in single-quoted PHP and JavaScript strings. +if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/]"; then echo "::error::plugin-name must not contain \\, \$, / or '."; exit 1; fi + +export SCREAMING_PREFIX="${PREFIX^^}" +export TESTS_PORT=$(( PORT + 1 )) +export REPO_NAME="${REPO#*/}" + +git mv --force README.scaffold.md README.md +git rm --quiet includes/_disabled-example.php +git mv dws-plugin-template.php "$SLUG.php" + +# GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the workflows read the slug from composer.json +# instead. The BEGIN filter drops symlinks (CLAUDE.md -> AGENTS.md), which in-place editing would turn into files. +# shellcheck disable=SC2016 +git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.sh' | xargs -0 perl -pi -e ' + BEGIN { @ARGV = grep { ! -l $_ } @ARGV } + s{ahegyes/wordpress-plugin-template}{$ENV{REPO}}g; + s/^# wordpress-plugin-template$/# $ENV{REPO_NAME}/; + s/dws-plugin-template/$ENV{SLUG}/g; + s/dws_tpl/$ENV{PREFIX}/g; + s/DWS_TPL/$ENV{SCREAMING_PREFIX}/g; + s/PluginTemplate/$ENV{NS}/g; + s/DWS Plugin Template/$ENV{PLUGIN_NAME}/g; +' + +# The locks' integrity hashes and version strings belong to other packages. +# shellcheck disable=SC2016 +git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json' | xargs -0 perl -pi -e ' + BEGIN { @ARGV = grep { ! -l $_ } @ARGV } + s/\b8811\b/$ENV{PORT}/g; + s/\b8812\b/$ENV{TESTS_PORT}/g; + s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/; +' +# shellcheck disable=SC2016 +perl -pi -e 's/^(\s*\*\s*Version:\s*)2\.0\.0$/${1}0.1.0/' "$SLUG.php" +perl -pi -e 's/^## 2\.0\.0 - unreleased$/## 0.1.0 - unreleased/' CHANGELOG.md +find changelog -type f ! -name '.gitkeep' -delete + +# The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails. +composer update --lock --no-install --no-scripts --no-plugins --ignore-platform-req=php+ --no-interaction + +composer validate --strict +php -l "$SLUG.php" +php -l uninstall.php +# shellcheck disable=SC2016 +for swept_json in .wp-env.json .wp-env.tests.json composer.json package.json package-lock.json; do + php -r 'json_decode( (string) file_get_contents( $argv[1] ), false, 512, JSON_THROW_ON_ERROR );' "$swept_json" +done + +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 + echo "::error::The fill-in left the template's names above." + status=1 +fi +if git grep -n -I -E '\b881[12]\b' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then + echo "::error::The fill-in left the template's ports above." + status=1 +fi +if ! git diff --quiet HEAD -- .github/workflows; then + echo "::error::The fill-in changed a workflow, which GitHub refuses to push with the job's token." + status=1 +fi +exit "$status" diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index dbb092a..7743fe3 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -1,28 +1,5 @@ name: Fill in scaffold -# Fork-only, one-shot: substitutes every template placeholder, renames the entry file, resets the -# version/changelog/metadata, and pushes the result back to the ref the workflow ran on. -# -# Placeholder inventory the sweep covers (each swept as a plain case-sensitive substring): -# dws-plugin-template slug + text-domain (also inside ahegyes/dws-plugin-template + the npm name) -# dws_plugin_template option-key base + settings-page slug (the trailing-underscore function -# prefix is this base plus `_`, so one substitution covers both forms) -# DWS_PLUGIN_TEMPLATE constants -# PluginTemplate namespace segment — covers DeepWebSolutions\PluginTemplate, the doubled- -# backslash JSON forms (DeepWebSolutions\\PluginTemplate\\ in composer.json), -# and the scoper prefix, since only the segment between backslashes changes -# DWS Plugin Template display name (plugin header, readme.txt, translated strings, e2e spec) -# ahegyes/wordpress-plugin-template repository-metadata URLs (composer homepage/authors, package.json -# repository/bugs, SECURITY.md advisory link, readme.txt changelog link, -# plugin header URI) — rewritten to the fork's own repository -# wordpress-plugin-template bare repo name — only as the AGENTS.md h1, so the rule is anchored to that -# heading (a blanket rule would re-match inside a fork repo name that itself -# contains the template name) -# 8811 wp-env port (.wp-env*.json, playwright.config.js, README, agent docs) -# 2.0.0 → 0.1.0 version reset (plugin header, _VERSION constant, readme Stable tag, -# @since/@version docblocks, test literals; the 1.0.0 pre-update seed in -# tests becomes 0.0.1 so the update-path test still seeds an older version) - on: workflow_dispatch: inputs: @@ -31,124 +8,59 @@ on: required: true type: string plugin-slug: - description: 'Kebab-case slug (e.g. "acme-orders")' + description: 'Kebab-case slug, also the text domain and the entry file name (e.g. "acme-orders")' required: true type: string php-namespace: - description: 'PHP namespace segment (e.g. "AcmeOrders")' + description: 'PHP namespace segment after DeepWebSolutions\ (e.g. "AcmeOrders")' + required: true + type: string + prefix: + description: 'Prefix of the global functions, constants, options and hooks, dws_ plus one segment (e.g. "dws_ao")' required: true type: string wp-env-port: - description: 'wp-env port' - required: false + description: 'Development wp-env port; the tests environment takes the next one (e.g. "8821")' + required: true type: string - default: '8811' -# contents: write + persisted credentials are the point: the job commits the filled-in scaffold and pushes it back. permissions: - contents: write + contents: read jobs: fill-in: name: Fill in scaffold - # One-shot fork bootstrap — running it on the template would destroy the placeholders it exists to substitute. - if: github.repository != 'ahegyes/wordpress-plugin-template' + # Running it on the template would destroy the placeholders it exists to fill in. + if: ${{ !github.event.repository.is_template }} runs-on: ubuntu-latest - env: - PLUGIN_NAME: ${{ inputs.plugin-name }} - SLUG: ${{ inputs.plugin-slug }} - NS: ${{ inputs.php-namespace }} - PORT: ${{ inputs.wp-env-port }} - REPO: ${{ github.repository }} + timeout-minutes: 10 + permissions: + contents: write steps: - - name: Explain the template-repo guard - run: echo "Running on $REPO — the job-level guard refuses the template repo itself, where the sweep would destroy the placeholders." - - - name: Validate inputs - run: | - # The slug feeds PHP constants and the entry-file rename — it must start with a letter and stay clean kebab-case. - echo "$SLUG" | grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' || { echo "::error::plugin-slug must be kebab-case starting with a letter (no leading/trailing/double hyphen)"; exit 1; } - echo "$NS" | grep -Eq '^[A-Za-z][A-Za-z0-9]*$' || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$"; exit 1; } - # The port lands in JSON numeric fields, where a leading zero is invalid JSON. - { echo "$PORT" | grep -Eq '^[1-9][0-9]{0,4}$' && [ "$PORT" -le 65535 ]; } || { echo "::error::wp-env-port must be 1-65535 with no leading zero"; exit 1; } - # The display name lands in perl replacements and in single-quoted PHP/JS string literals. - if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/]"; then echo "::error::plugin-name must not contain \\, \$, /, or '"; exit 1; fi - - # persist-credentials stays on: this job's whole purpose is pushing the filled-in scaffold back. + # The job pushes the filled-in plugin back, so the checkout keeps its credentials. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: true - - name: Compute derived forms - run: | - { - echo "SNAKE=$(echo "$SLUG" | tr '-' '_')" - echo "SCREAMING=$(echo "$SLUG" | tr '-' '_' | tr '[:lower:]' '[:upper:]')" - echo "REPO_NAME=${REPO#*/}" - } >> "$GITHUB_ENV" - - - name: Substitute placeholders - # The sweep skips this workflow file so the repo guard keeps its literal template name; a re-run - # after fill-in finds nothing left to substitute. - run: | - # $ENV{...} is perl, resolved inside the single-quoted program on purpose. The BEGIN filter drops - # symlinks (CLAUDE.md -> AGENTS.md): in-place editing through one would replace it with a regular file. - # shellcheck disable=SC2016 - git ls-files -z | grep -zv '^.github/workflows/fill-in-scaffold.yml$' | xargs -0 perl -pi -e ' - BEGIN { @ARGV = grep { ! -l $_ } @ARGV } - 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{SNAKE}/g; - s/DWS_PLUGIN_TEMPLATE/$ENV{SCREAMING}/g; - s/PluginTemplate/$ENV{NS}/g; - s/DWS Plugin Template/$ENV{PLUGIN_NAME}/g; - ' - - - name: Substitute the wp-env port - # CLAUDE.md is a symlink to AGENTS.md, so sweeping AGENTS.md covers both. - run: | - perl -pi -e 's/8811/$ENV{PORT}/g' .wp-env.json .wp-env.belowfloor.json playwright.config.js README.md AGENTS.md - - - name: Rename the entry file - run: git mv dws-plugin-template.php "$SLUG.php" - - - name: Reset the version to 0.1.0 - run: | - # ${1} is a perl backreference inside the single-quoted program. - # shellcheck disable=SC2016 - # Docblock lines first (all tracked text files except the lockfiles, whose 2.0.0 hits are foreign packages). - git ls-files -z | grep -zv -e '^composer.lock$' -e '^package-lock.json$' | xargs -0 perl -pi -e 'BEGIN { @ARGV = grep { ! -l $_ } @ARGV } s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/' - # Entry file: plugin header Version + the _VERSION constant line. - perl -pi -e 's/\b2\.0\.0\b/0.1.0/g' "$SLUG.php" - # readme.txt Stable tag. - perl -pi -e 's/^(Stable tag:\s*)2\.0\.0$/${1}0.1.0/' readme.txt - # Tests assert against the constant's value; the 1.0.0 pre-update seed must stay below the new version. - git ls-files -z 'tests/*' | xargs -0 perl -pi -e 's/\b2\.0\.0\b/0.1.0/g; s/\b1\.0\.0\b/0.0.1/g' - - - name: Reset the changelog - run: | - perl -0pi -e 's/.*/\n\n## 0.1.0 - unreleased\n\n/s' CHANGELOG.md readme.txt - find changelog -type f ! -name '.gitkeep' -delete - - - name: Reset readme.txt prose to placeholders - run: | - perl -0pi -e 's/== Description ==.*?== Installation ==/== Description ==\n\nTODO: Describe the plugin for the wp.org listing.\n\n== Installation ==/s' readme.txt - perl -pi -e 's/^(Tags:\s*).*$/${1}TODO/' readme.txt + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + coverage: none + tools: composer:v2 - - name: Verify the result - run: | - composer validate --no-check-lock - php -l "$SLUG.php" - php -l uninstall.php - # The sweep rewrote these as text — prove they are still valid JSON before anything is pushed. - # shellcheck disable=SC2016 - for swept_json in .wp-env.json .wp-env.belowfloor.json composer.json package.json package-lock.json; do - php -r 'json_decode( (string) file_get_contents( $argv[1] ), false, 512, JSON_THROW_ON_ERROR );' "$swept_json" - done + - name: Fill in the placeholders + env: + PLUGIN_NAME: ${{ inputs.plugin-name }} + SLUG: ${{ inputs.plugin-slug }} + NS: ${{ inputs.php-namespace }} + PREFIX: ${{ inputs.prefix }} + PORT: ${{ inputs.wp-env-port }} + REPO: ${{ github.repository }} + run: .github/scripts/fill-in-scaffold.sh - # Pushes back to the ref the workflow ran on — run it on trunk for the standard fork flow. - name: Commit and push + env: + SLUG: ${{ inputs.plugin-slug }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index a46928e..07f781a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -26,12 +26,34 @@ jobs: name: block.json schema uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + slug: + name: Plugin slug + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + slug: ${{ steps.slug.outputs.slug }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the fill-in leaves workflows alone and they read the slug the plugin declares. + - name: Read the slug from composer.json + id: slug + run: | + slug=$(jq -r '.extra["text-domain"] // empty' composer.json) + if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." + exit 1 + fi + echo "slug=$slug" >> "$GITHUB_OUTPUT" + php-syntax: name: PHP syntax + needs: slug uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 with: php-versions: '["7.4"]' - paths: '["dws-plugin-template.php", "uninstall.php", "includes"]' + paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }} changelog: name: Changelog fragments @@ -47,3 +69,34 @@ jobs: tools: composer:v2 - uses: ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda # 4.0.0 - run: composer changelog:validate + + scaffold: + name: Scaffold rehearsal + # A plugin generated from the template has filled in its placeholders already, so only the template rehearses the fill-in. + if: github.event.repository.is_template + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + with: + php-version: '8.5' + coverage: none + tools: composer:v2 + - name: Fill in the scaffold + env: + PLUGIN_NAME: Acme Orders + SLUG: acme-orders + NS: AcmeOrders + PREFIX: dws_ao + PORT: '8821' + REPO: acme/acme-orders + run: .github/scripts/fill-in-scaffold.sh + - name: Install and scope the filled-in plugin + run: composer packages-install + - name: Lint and unit-test the filled-in plugin + run: | + composer lint:php + composer test:unit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d491b49..75e856d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,30 @@ permissions: contents: read jobs: + slug: + name: Plugin slug + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + slug: ${{ steps.slug.outputs.slug }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the fill-in leaves workflows alone and they read the slug the plugin declares. + - name: Read the slug from composer.json + id: slug + run: | + slug=$(jq -r '.extra["text-domain"] // empty' composer.json) + if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + echo "::error::composer.json's extra.text-domain must be the plugin's kebab-case slug — got '$slug'." + exit 1 + fi + echo "slug=$slug" >> "$GITHUB_OUTPUT" + release: name: Release + needs: slug # A reusable workflow can't elevate above the caller's token; grant the provenance check's and the GitHub Release's scopes here. permissions: actions: read @@ -23,7 +45,7 @@ jobs: uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 with: project-path: '.' - plugin-slug: 'dws-plugin-template' + plugin-slug: ${{ needs.slug.outputs.slug }} wp-env-config-file: '.wp-env.tests.json' # Both parse every PHP file with php-parser 4, which stops at PHP 8.4 syntax such as protected(set). exclude-checks: 'prefixing,direct_file_access' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b436825..ba2845e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,13 +31,14 @@ jobs: with: persist-credentials: false - # The floor is the plugin's Requires at least header, and WordPress's version API offers the newest release of every branch, so new releases and floors join without an edit here. + # The floor is the Requires at least header of the entry file composer.json's text domain names, and WordPress's version API offers the newest release of every branch, so new releases, floors and a filled-in slug join without an edit here. - name: List the newest release of every branch from the floor up id: versions run: | - floor=$(sed -nE 's|^[[:space:]/*#@]*Requires at least:[[:space:]]*([0-9]+(\.[0-9]+)*).*$|\1|p' dws-plugin-template.php | head -n 1) + entry="$(jq -r '.extra["text-domain"] // empty' composer.json).php" + floor=$(sed -nE 's|^[[:space:]/*#@]*Requires at least:[[:space:]]*([0-9]+(\.[0-9]+)*).*$|\1|p' "$entry" | head -n 1) if [ -z "$floor" ]; then - echo "::error::dws-plugin-template.php declares no Requires at least header." + echo "::error::$entry declares no Requires at least header." exit 1 fi offers=$(curl -fsS https://api.wordpress.org/core/version-check/1.7/) diff --git a/README.scaffold.md b/README.scaffold.md new file mode 100644 index 0000000..d4d6954 --- /dev/null +++ b/README.scaffold.md @@ -0,0 +1,65 @@ +# DWS Plugin Template + +TODO: one sentence saying why this plugin exists, its philosophy rather than a list of what it holds. The same sentence is `composer.json`'s `description`, the entry file's `Description` header and the repository's GitHub description. + +## Requirements + +The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. On a site below them, WordPress shows its own notice and none of the plugin's features load. + +## Installation + +Download `dws-plugin-template.zip` from the repository's latest release, then upload it under Plugins → Add New Plugin → Upload Plugin. Later releases arrive through WordPress's own update check, from the repository the entry file's `Update URI` names. + +## Development + +Development needs PHP and Composer at the `Requires PHP` floor or later, Node.js and npm at `package.json`'s `engines`, Docker for `@wordpress/env`, and a POSIX shell for `npm run start`. + +Install the dependencies, prefix the framework into `vendor-prefixed/`, build the scripts and start the development site on : + +```sh +npm run setup +``` + +Rebuild the blocks and assets on every change: + +```sh +npm run start +``` + +Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integration suites, starting the tests site on as needed: + +```sh +composer quality-check +``` + +Run the end-to-end suite against the tests site: + +```sh +npm run test:e2e +``` + +Lint the scripts, styles and types: + +```sh +npm run lint:scripts && npm run lint:styles && npm run lint:types +``` + +`docs/ARCHITECTURE.md` describes how the plugin loads and boots, `docs/recipes.md` holds the step-by-step changes most plugins need, `tests/README.md` describes the test suites, and `AGENTS.md` holds the conventions for people and agents working on the code. + +## After the fill-in + +The fill-in renamed the plugin and kept the template's demo, which boots and passes every suite. Replace or delete each part as the plugin grows its own: + +| 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/`, `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`. `tests/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks`, points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, and `BlocksTest`'s editor row; 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. | +| 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. | + +Still to decide by hand: + +- The Composer package name: the fill-in set `ahegyes/dws-plugin-template`, while plugins published to Composer use `ahegyes/wc-` for WooCommerce and `ahegyes/wp-` otherwise. +- WooCommerce's order tables: `dws_tpl_declare_compatibility()` in `functions.php` declares the plugin incompatible until its order code is verified on both storages. +- `SECURITY.md`'s scope and the issue forms in `.github/ISSUE_TEMPLATE/`, whose wording describes this plugin's demo. From cf487e71c88c8ec5b49b17b5d0dba79037b41883 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:11:26 +0100 Subject: [PATCH 03/15] Teach the changes most plugins make as recipes Each plugin built from the template re-derived the same changes from the framework's sources: a safeguard, a one-time notice, a settings page, a product tab, an edit-screen form, a WooCommerce section, recurring jobs, rewrite rules, a script module, a hook rename, wordpress.org distribution and a build without WooCommerce. A wrong copy of one, such as a relaxation that a half-loaded build can trigger, spreads to every plugin that has it. docs/recipes.md now holds them as the files to add or change. The safeguard recipe carries dws_tpl_is_relaxed(), which takes the filter's value so each safeguard keeps its literal, documented hook, and its failure test, which the Integration suite runs against the recipe's own block. Each recipe's code was probed against the framework: lint, static analysis and the PHP 7.4 parse, the screens and the schedule on a running site, and a full gate on a copy built without WooCommerce. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 1291 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1291 insertions(+) create mode 100644 docs/recipes.md diff --git a/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..9c18fb8 --- /dev/null +++ b/docs/recipes.md @@ -0,0 +1,1291 @@ +# Recipes + +These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces; anything stored under the plugin's `dws_tpl_` prefix, or `_dws_tpl_` for hidden meta, is in the footprint already. + +- [A safeguard](#a-safeguard) +- [A one-time notice](#a-one-time-notice) +- [A WordPress settings page](#a-wordpress-settings-page) +- [A product data tab](#a-product-data-tab) +- [A form on an edit screen](#a-form-on-an-edit-screen) +- [A section on WooCommerce's Payments tab](#a-section-on-woocommerces-payments-tab) +- [Recurring jobs](#recurring-jobs) +- [Rewrite rules after activation](#rewrite-rules-after-activation) +- [A script module](#a-script-module) +- [Renaming a public hook](#renaming-a-public-hook) +- [Distribution through wordpress.org](#distribution-through-wordpressorg) +- [Without WooCommerce](#without-woocommerce) + +## A safeguard + +A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the autoloader, so it calls only WordPress and what that file defines. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl_initialized`, which fires once the plugin has booted, so a half-loaded build never relaxes it. + +Replace `includes/safeguards.php` with the helper and the safeguard. The file must still parse on PHP 7.4, because the entry loads it before the floor gate passes on a site without the plugin's built files: + +```php +export(); // Checks current_user_can( 'dws_tpl_export_data' ). +} finally { + remove_filter( 'dws_tpl_restrict_data_export', '__return_false' ); +} +``` + +Add the safeguard's failure test as `tests/Integration/SafeguardsTest.php`. The plugin has booted when the Integration suite runs, so the last row takes `dws_tpl_initialized` back for its duration: + +```php + array( array(), false ), + 'null' => array( array( 'relaxation' => null ), false ), + 'zero' => array( array( 'relaxation' => 0 ), false ), + 'empty-string' => array( array( 'relaxation' => '' ), false ), + 'false' => array( array( 'relaxation' => false ), true ), + ); + } + + #[DataProvider( 'relaxations' )] + public function test_only_an_explicit_false_relaxes_the_export_lock( array $spec, bool $relaxed ): void { + $relaxation = static fn (): mixed => $spec['relaxation']; + if ( \array_key_exists( 'relaxation', $spec ) ) { + add_filter( self::FILTER, $relaxation ); + } + + try { + self::assertSame( $relaxed ? array( 'manage_options' ) : array( 'do_not_allow' ), map_meta_cap( 'dws_tpl_export_data', 0 ) ); + } finally { + remove_filter( self::FILTER, $relaxation ); + } + } + + public function test_an_explicit_false_does_not_relax_the_export_lock_before_the_plugin_booted(): void { + global $wp_actions; + self::assertGreaterThan( 0, did_action( 'dws_tpl_initialized' ) ); + $fired = $wp_actions['dws_tpl_initialized']; + unset( $wp_actions['dws_tpl_initialized'] ); + add_filter( self::FILTER, '__return_false' ); + + try { + self::assertSame( array( 'do_not_allow' ), map_meta_cap( 'dws_tpl_export_data', 0 ) ); + } finally { + remove_filter( self::FILTER, '__return_false' ); + $wp_actions['dws_tpl_initialized'] = $fired; + } + } +} +``` + +## A one-time notice + +A one-time notice shows once, on the user's next admin screen, such as after a form post redirects. The plugin's `AdminNotices` is in `Plugin::COMPONENTS` and in the container, so a component takes it in its constructor: + +```php +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\Notice; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\NoticeType; + +// ... + + /** + * Constructor. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param AdminNotices $notices The plugin's admin notices. + */ + public function __construct( + protected AdminNotices $notices + ) {} + + // ... + + /** + * Tells the current user, on their next admin screen, how many rows the export wrote. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param int $rows The number of rows the export wrote. + * + * @return void + */ + protected function report_export( int $rows ): void { + $this->notices->add( + new Notice( + Plugin::PREFIX . '-export-' . time(), // A viewer's delivery outlives the notice, so each occasion takes a new id. + /* translators: %d: The number of rows. */ + esc_html( sprintf( _n( 'The export wrote %d row.', 'The export wrote %d rows.', $rows, 'dws-plugin-template' ), $rows ) ), + NoticeType::Success + ) + ); + } +``` + +The notice's scope defaults to the current user and its message is HTML, so escape it. A notice for every administrator of the site takes `NoticeScope::Site` and a capability; one that stays until dismissed takes `persistent: true`. + +## A WordPress settings page + +An options page adds a screen under a WordPress admin menu and stores each tab's fields in one option through the Settings API. WooCommerce settings belong on a WooCommerce tab, as `src/Settings.php` does. + +Add the option, its field and its default, a reader that guards the field and the validator to `includes/options.php`, which stays PHP 7.4-parseable. The non-WooCommerce recipe below uses the same names, with the uninstall consent as a second field: + +```php +/** + * The option that holds the settings page's fields, keyed by field id. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; + +/** + * The settings field that holds the limit. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_TPL_SETTING_LIMIT = 'limit'; + +/** + * The value each settings field reads as while nothing valid is stored. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var array + */ +const DWS_TPL_SETTING_DEFAULTS = array( + DWS_TPL_SETTING_LIMIT => 1, +); + +/** + * Returns a settings field as an integer. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $field The field's id. + * + * @return int + */ +function dws_tpl_get_int_setting( string $field ): int { + $default = DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; + $stored = get_option( DWS_TPL_OPTION_SETTINGS ); + $stored = is_array( $stored ) && array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; + + return filter_var( $stored, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); +} + +/** + * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param mixed $submitted The submitted value. + * + * @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; + + return false === $limit ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ) : $limit; +} +``` + +Add the page as a component, `src/SettingsPage.php`, and list it in `Plugin::COMPONENTS`: + +```php +register_hooks(); + } + + /** + * {@inheritDoc} + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function get_options_page( string $page_id ): array { + return array( + 'parent' => 'options-general.php', + 'capability' => 'manage_options', + 'page_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + 'menu_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + 'tabs' => array( + array( + 'slug' => 'general', + 'title' => __( 'General', 'dws-plugin-template' ), + 'option' => DWS_TPL_OPTION_SETTINGS, + 'fields' => array( + array( + 'type' => 'number', + 'id' => DWS_TPL_SETTING_LIMIT, + 'title' => __( 'Limit', 'dws-plugin-template' ), + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => 'dws_tpl_validate_limit', + ), + ), + ), + ), + ); + } + + // endregion +} +``` + +Read the limit with `dws_tpl_get_int_setting( DWS_TPL_SETTING_LIMIT )`, which returns the default until a valid limit is stored. The option is under the plugin's prefix, so the footprint covers it; a migration step that stores `DWS_TPL_SETTING_DEFAULTS` autoloaded saves the first request's query, as the non-WooCommerce recipe's step does. + +## A product data tab + +A product data tab adds a panel to the product editor's Product data box, whose fields WooCommerce renders and saves as the product's meta. Add it as a component, `src/ProductData.php`, and list it in `Plugin::COMPONENTS`: + +```php +register_hooks(); + } + + /** + * {@inheritDoc} + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function get_product_data_tab( string $tab_id, \WC_Product $product ): array { + return array( + 'label' => __( 'Packing', 'dws-plugin-template' ), + 'groups' => array( + array( + 'fields' => array( + array( + 'type' => 'text', + 'id' => '_' . Plugin::PREFIX . '_box', + 'label' => __( 'Box', 'dws-plugin-template' ), + ), + ), + ), + ), + ); + } + + // endregion +} +``` + +Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box, and `_dws_tpl_` meta is in the footprint's family; declare it for uninstall in `includes/footprint.php`: + +```php +'meta_prefixes' => array( 'post' => array( '_' . Plugin::PREFIX . '_' ) ), +``` + +A field with its own `render` callable returns its control as inline markup, escapes, names and describes it, and prints a hidden empty input before it unless the field is disabled. Without that input, a cleared control posts nothing, and the save leaves the stored value untouched. + +## A form on an edit screen + +An object form adds fields to an edit screen, such as a post's meta box, a user's profile or a term's form, and stores each field on the edited object. It reports a rejected value through the plugin's `AdminNotices`. Add it as a component, `src/PostNote.php`, and list it in `Plugin::COMPONENTS`: + +```php +notices )->register_hooks(); + } + + /** + * {@inheritDoc} + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function get_object_form( string $form_id, int $object_id ): array { + return array( + 'title' => __( 'Editorial note', 'dws-plugin-template' ), + 'capability' => 'edit_posts', + 'fields' => array( + array( + 'type' => 'textarea', + 'id' => '_' . Plugin::PREFIX . '_note', + 'title' => __( 'Note', 'dws-plugin-template' ), + ), + ), + ); + } + + // endregion +} +``` + +The host checks that the user can edit the post itself, and the form's `capability` is one more capability the user needs, checked without an object, so it names a primitive capability such as `edit_posts` rather than `edit_post`. The form verifies its own nonce before it asks for the definition on a save. Declare the meta for uninstall in `includes/footprint.php`, as the product data tab does. A field with its own `render` callable prints a hidden empty input before its control unless the field is disabled, for the reason that recipe gives. + +## A section on WooCommerce's Payments tab + +A section on an existing WooCommerce tab, here the Payments tab (`checkout`), holds settings that belong beside WooCommerce's own. WooCommerce renders the Payments tab as its own page and lists no extension sections there, so the section is reached through its direct link, which the plugin's row on the Plugins screen carries. Add it as a component, `src/PaymentSettings.php`, and list it in `Plugin::COMPONENTS`: + +```php + 'title', + 'id' => Plugin::PREFIX . '_payment_options', + 'title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + ), + array( + 'type' => 'checkbox', + 'id' => Plugin::PREFIX . '_payment_notes', + 'title' => __( 'Payment notes', 'dws-plugin-template' ), + 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), + ), + array( + 'type' => 'sectionend', + 'id' => Plugin::PREFIX . '_payment_options', + ), + ); + } + + // endregion + + // region HOOKS + + /** + * Registers the plugin's section on the Payments tab. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param SettingsSectionRegistry $registry WooCommerce's registry of settings sections. + * + * @return void + */ + public function register_section( SettingsSectionRegistry $registry ): void { + $registry->register( + new SettingsSection( + Plugin::PREFIX, + array( + 'tab' => 'checkout', + 'label' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + ), + $this + ) + ); + } + + /** + * Adds the link to the plugin's section to its row on the Plugins screen. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string[] $links The row's action links. + * + * @return string[] + */ + public function add_settings_link( array $links ): array { + array_unshift( $links, sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); + + return $links; + } + + // endregion +} +``` + +WooCommerce stores each field in its own option under its id, flat rather than in a group, and a WooCommerce checkbox stores `yes` or `no`; read it with `dws_tpl_get_bool_option()` once the key and its default are in `includes/options.php`. + +## Recurring jobs + +A recurring job runs a hook at an interval through the plugin's scheduler, which uses Action Scheduler where it is loaded and WP-Cron otherwise. Either can run an invocation twice or skip one, so the job's callback leaves its data consistent after a duplicate or a missed run. + +Add the repair gate's option to `includes/options.php`: + +```php +/** + * The option that holds when the plugin last ensured its recurring jobs, which lets a request skip ensuring them for an hour. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @var string + */ +const DWS_TPL_OPTION_RECURRING_ENSURED_AT = Plugin::PREFIX . '_recurring_ensured_at'; +``` + +Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, writing the time first so concurrent requests skip, and again when Action Scheduler asks every day: + +```php +ensure_schedule(); + } + + /** + * Schedules the daily prune unless it runs at that interval already, logging a scheduler that refuses. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return void + */ + public function ensure_schedule(): void { + $ensured = $this->scheduler->ensure_recurring( new ScheduledHook( self::HOOK ), Recurrence::from_interval( DAY_IN_SECONDS ) ); + if ( true !== $ensured ) { + $this->logger->warning( + 'Could not schedule {hook}: {error}', + array( + 'hook' => self::HOOK, + 'error' => $ensured->get_error_message(), + 'exception' => $ensured->get_error_data(), + ) + ); + } + } + + /** + * Deletes the records that have expired. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return void + */ + public function prune(): void { + // Delete by expiry, so a second run in a day finds nothing left and a missed day catches up. + } + + // endregion +} +``` + +Then: +- Add `Plugin::PREFIX . '_prune'` to `cron_hooks` in `includes/footprint.php`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. +- Delete the repair gate's option on activation, so the next request ensures the jobs: add `register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' );` to `functions.php` beside the deactivation hook, and a `dws_tpl_activate()` that calls `delete_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT )`. +- Change an interval in code only: `ensure_recurring()` replaces a schedule whose interval differs on the next pass of the repair gate. A "run now" schedules the same hook with an argument that sets it apart, or calls `prune()` directly. + +## Rewrite rules after activation + +WordPress builds its rewrite rules from the post types and taxonomies registered on `init`. The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`, so flushing there would drop the plugin's rules. Delete the stored rules instead: the next request rebuilds them once the plugin has registered its types. + +In `functions.php`, beside the deactivation hook: + +```php +register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' ); + +// ... + +/** + * Lets the next request rebuild the site's rewrite rules with the plugin's post types and taxonomies. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return void + */ +function dws_tpl_activate(): void { + delete_option( 'rewrite_rules' ); +} +``` + +Add the same `delete_option( 'rewrite_rules' )` to `dws_tpl_deactivate()`, so the next request rebuilds the rules without them. Both act on the current site only, as WordPress's own activation does; a network activation leaves the other sites' rules until they next rebuild them. + +## A script module + +A script module is a JavaScript module WordPress loads through its import map, such as a block's view script for the Interactivity API. wp-scripts builds script modules only from a block's `block.json`, and WordPress registers each one from the block's metadata, with the imports wp-scripts recorded, and enqueues it when the block renders, so the plugin registers nothing itself. + +Name the module in the block's `block.json`, here `blocks/src/example-notice/block.json`: + +```json +"viewScriptModule": "file:./view.js" +``` + +Add the module beside it, as `blocks/src/example-notice/view.ts`: + +```ts +import { store } from '@wordpress/interactivity'; + +store( 'dws-plugin-template/example-notice', {} ); +``` + +Declare the module's import, which today arrives only through `@wordpress/block-editor`: + +```sh +npm install --save-dev @wordpress/interactivity +``` + +Build the blocks' modules with their scripts, by adding `--experimental-modules` to `build:blocks` and `start:blocks` in `package.json`: + +```json +"build:blocks": "wp-scripts build --webpack-src-dir=blocks/src --output-path=blocks/build --blocks-manifest --experimental-modules", +"start:blocks": "wp-scripts start --webpack-src-dir=blocks/src --output-path=blocks/build --blocks-manifest --experimental-modules" +``` + +The build writes `view.js` with a `view.asset.php` whose `type` is `module`, and `src/Blocks.php`'s registration from the manifest picks it up. + +## Renaming a public hook + +A public hook's name is part of the plugin's contract, as its arguments, its options and its REST routes are. To rename one in a released plugin: + +1. Rename the literal at every `do_action()` or `apply_filters()` and in its docblock, which keeps its `@since` and moves its `@version` to the release. +2. Add a changelog fragment with `composer changelog:add` that names the old and the new hook, with a major significance. +3. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. + +Ship no deprecated alias: the major release is the migration path. + +## Distribution through wordpress.org + +A plugin listed on wordpress.org gets its updates and its language packs from there, so it drops the GitHub updater's header and adds the directory's `readme.txt`. + +1. In the entry file, delete the `Update URI` header, which turns the bundled updater inert, and the `load_plugin_textdomain()` line, since language packs load just in time. +2. Add `readme.txt`, whose `Stable tag` the release checks against the `Version` header, whose floors repeat the entry file's headers, and whose changelog block `composer changelog:write` fills: + +```text +=== DWS Plugin Template === +Contributors: TODO +Tags: TODO +Tested up to: TODO, the newest WordPress branch the plugin is tested on +Stable tag: TODO, the Version header +Requires at least: TODO, the Requires at least header +Requires PHP: TODO, the Requires PHP header +License: GPLv2 or later +License URI: https://www.gnu.org/licenses/gpl-2.0.html + +TODO: the plugin's one sentence, at most 150 characters. + +== Description == + +TODO: what the plugin does, for the directory's listing. + +== Installation == + +1. Install the plugin from Plugins → Add New Plugin. +2. Activate it. + +== Changelog == + + + + + +[See the full changelog on GitHub.](https://github.com/ahegyes/wordpress-plugin-template/blob/trunk/CHANGELOG.md) +``` + +3. Wrap `CHANGELOG.md`'s version blocks in the same `` and `` markers, require `pronamic/changelog-md-to-wordpress-plugin-readme-txt` in `require-dev`, and make `changelog:write` sync the file: + +```json +"changelog:write": [ + "@php vendor/bin/changelogger write", + "@php vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt" +] +``` + +4. Lint the file in `.github/workflows/quality.yml`: + +```yaml + readme-lint: + name: readme.txt lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: thetwopct/wporg-plugin-readme-linter@1f869aba959265ecc90935b2380c6569d56646ac # v1.0.2 + with: + path: readme.txt + fail-on: error +``` + +5. In `.github/workflows/release.yml`, pass `wp-org: true` to the reusable release. A tag then also deploys the zip and the `.wordpress-org/` assets to the directory's SVN from the `wp-org-release` environment: create it, store `SVN_USERNAME` and `SVN_PASSWORD` there and only there, and protect it with required reviewers. + +The wordpress.org build runs Plugin Check's full set but for the checks `exclude-checks` names; Plugin Check's `prefixing` and `direct_file_access` stop at PHP 8.4 syntax such as `protected(set)`, which wordpress.org's own scan may meet too. The bundled updater ships inert under `vendor-prefixed/`, which wordpress.org's reviewers may still ask to remove. + +## Without WooCommerce + +A plugin that does not need WooCommerce drops the WooCommerce package, the WooCommerce demo and its tooling, and keeps its settings on a WordPress options page. + +1. In `composer.json`, delete the `vcs` repository entry whose `url` is `https://github.com/ahegyes/wp-framework-woocommerce`. Then remove the WooCommerce packages, which leaves every other locked version as it is: + +```sh +composer remove --dev --ignore-platform-req=php+ --no-interaction ahegyes/wp-framework-woocommerce php-stubs/woocommerce-stubs wp-plugin/woocommerce +``` + +2. In `phpstan.dist.neon`, delete the `scanFiles` block that names the WooCommerce stubs: + +```neon + scanFiles: + - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php + - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php +``` + +3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers. + +4. In `functions.php`, delete the `FeaturesUtil` import, the `before_woocommerce_init` hook and `dws_tpl_declare_compatibility()`, and drop "declares its WooCommerce feature compatibility" from the file's summary: + +```php +use DeepWebSolutions\PluginTemplate\Plugin; + +defined( 'ABSPATH' ) || exit; + +add_action( 'plugins_loaded', 'dws_tpl_boot', 15 ); +register_deactivation_hook( DWS_TPL_FILE, 'dws_tpl_deactivate' ); +``` + +5. In `src/Plugin.php`, log through core's `ErrorLogLogger`, which drops records below warning unless `WP_DEBUG` is on, so `DWS_TPL_DEBUG` goes. Compose without the WooCommerce check, and leave the greeting out of `COMPONENTS`: + +```php +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\AdminNotice\AdminNotices; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Logging\ErrorLogLogger; +use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +// ... + + public const array COMPONENTS = array( + Scheduler::class, + AdminNotices::class, + Settings::class, + Blocks::class, + ); + + /** + * The scheduler, which the components and the deactivation callback share. + // ... + */ + public readonly Scheduler $scheduler; + + // ... + + 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 ); + } + + // ... + + /** + * Registers the scheduler and the migrations, then builds the kernel and boots the plugin's components. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @return bool + */ + 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->register_hooks(); + + $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); + $this->kernel->boot( self::COMPONENTS ); + + return true; + } +``` + +6. Delete the greeting: `src/Feature/`, `tests/Integration/Feature/` 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", +"start:assets": "wp-scripts start editor=./assets/src/editor.ts --output-path=assets/build" +``` + +7. Replace `src/Settings.php` with an options page under Settings, which stores both fields in one option through the Settings API: + +```php +register_hooks(); + } + + /** + * {@inheritDoc} + * + * @since 2.0.0 + * @version 2.0.0 + */ + #[\Override] + public function get_options_page( string $page_id ): array { + return array( + 'parent' => 'options-general.php', + 'capability' => 'manage_options', + 'page_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + 'menu_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), + 'tabs' => array( + array( + 'slug' => 'general', + 'title' => __( 'General', 'dws-plugin-template' ), + 'option' => DWS_TPL_OPTION_SETTINGS, + 'fields' => array( + array( + 'type' => 'number', + 'id' => DWS_TPL_SETTING_LIMIT, + 'title' => __( 'Limit', 'dws-plugin-template' ), + 'custom_attributes' => array( + 'min' => '1', + 'step' => '1', + ), + 'validate' => 'dws_tpl_validate_limit', + ), + 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' ), + ), + ), + ), + ), + ); + } + + // endregion +} +``` + +8. Replace `includes/options.php` with the one settings option, its fields, their defaults, and readers that guard each field. The file stays PHP 7.4-parseable, because Quality's syntax job lints `includes/`: + +```php + + */ +const DWS_TPL_SETTING_DEFAULTS = array( + DWS_TPL_SETTING_DELETE_DATA => false, + DWS_TPL_SETTING_LIMIT => 1, +); + +/** + * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $field The field's id. + * + * @return bool + */ +function dws_tpl_get_bool_setting( string $field ): bool { + $default = dws_tpl_get_setting_default( $field ); + + return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); +} + +/** + * Returns a settings field as an integer. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $field The field's id. + * + * @return int + */ +function dws_tpl_get_int_setting( string $field ): int { + $default = dws_tpl_get_setting_default( $field ); + + return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); +} + +/** + * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param mixed $submitted The submitted value. + * + * @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; + + return false === $limit ? new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ) : $limit; +} + +/** + * Returns a settings field's stored value, or the fallback while the settings hold none. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $field The field's id. + * @param mixed $fallback The value to return while nothing is stored. + * + * @return mixed + */ +function dws_tpl_get_setting( string $field, $fallback ) { + $settings = get_option( DWS_TPL_OPTION_SETTINGS ); + + return is_array( $settings ) && array_key_exists( $field, $settings ) ? $settings[ $field ] : $fallback; +} + +/** + * Returns a settings field's default, or null for a field without one. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @param string $field The field's id. + * + * @return mixed + */ +function dws_tpl_get_setting_default( string $field ) { + return DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; +} +``` + +9. In `includes/migrations.php`, the first step stores the defaults as they are. A stored `false` renders as an unchecked checkbox, the readers guard it, and uninstall consents only on `'yes'`: + +```php +/** + * Stores the settings' defaults, autoloaded, so a site that never saved the settings page reads them without a query. + * + * @since 2.0.0 + * @version 2.0.0 + * + * @throws RuntimeException Thrown when the defaults could not be stored. + * + * @return void + */ +function dws_tpl_add_settings(): void { + if ( false === get_option( DWS_TPL_OPTION_SETTINGS ) && ! add_option( DWS_TPL_OPTION_SETTINGS, DWS_TPL_SETTING_DEFAULTS, '', true ) ) { + throw new RuntimeException( 'The option ' . DWS_TPL_OPTION_SETTINGS . ' could not be stored.' ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing, never rendered. + } +} +``` + +10. In `uninstall.php`, delete the `MetaTypes` import, 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 ) +); +``` + +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`: + +```json +"mappings": { + "wp-content/plugins/dws-plugin-template": "." +}, +"lifecycleScripts": { + "afterStart": "node_modules/.bin/wp-env --config .wp-env.tests.json run cli wp plugin activate dws-plugin-template" +} +``` + +12. Adapt the tests: +- Delete `tests/e2e/without-woocommerce.spec.ts`. +- `OptionsTest` reads through `dws_tpl_get_bool_setting()` and `dws_tpl_get_int_setting()` from a `DWS_TPL_OPTION_SETTINGS` row; a malformed limit reads its default 1; the validator rows call `dws_tpl_validate_limit()`, which returns integers. +- `MigrationsTest` expects `DWS_TPL_SETTING_DEFAULTS` stored autoloaded, a stored row kept as it is, and the throw when that option's `INSERT` fails. +- `UninstallTest` consents through `array( DWS_TPL_SETTING_DELETE_DATA => 'yes' )`, seeds and asserts no Action Scheduler action, and expects the defaults stored again afterwards. +- `PluginBootTest` expects nothing in `$plugin->kernel->skipped`. +- `SettingsTest` builds `new OptionsPage( Plugin::PREFIX, new Settings() )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: + +```php + // options.php saves a submission through update_option(), whose filter the page hooks for its own option page only. + protected function save( array $posted ): void { + $GLOBALS['pagenow'] = 'options.php'; + $_REQUEST['option_page'] = DWS_TPL_OPTION_SETTINGS; + + try { + update_option( DWS_TPL_OPTION_SETTINGS, $posted ); + } finally { + unset( $GLOBALS['pagenow'], $_REQUEST['option_page'] ); + } + } +``` + +- `tests/e2e/settings.spec.ts` drives the page's limit field with the same checks: it focuses `page.getByLabel( 'Limit' )`, tabs to the `Save Changes` button and scopes axe to the form: + +```ts +const SAVED = 'Settings saved.'; +const REJECTED = + 'Limit was not saved, so it keeps its previous value. Enter a whole number of 1 or more, such as 3.'; +const FORM = 'form[action="options.php"]'; + +// ... + + test.beforeEach( async ( { admin } ) => { + await admin.visitAdminPage( 'options-general.php', 'page=dws_tpl' ); + } ); +``` + +13. Update `AGENTS.md`, `README.md` and `docs/ARCHITECTURE.md` wherever they name WooCommerce, the greeting, the settings tab or the order-tables declaration. From b7d529f9f5b68f99106f2cecf572932414696aed Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:11:26 +0100 Subject: [PATCH 04/15] Describe the plugin's anatomy, tests and conventions as built The README, AGENTS.md and SECURITY.md still described the v1 template, with its singleton plugin, installer, config/ files, dependencies/ directory and placeholder table, and nothing described the boot, the data or the suites. The README describes starting a plugin from the template and the fill-in's inputs and naming; docs/ARCHITECTURE.md the loading, the boot, where code lives and the data; tests/README.md the suites, environments and CI; AGENTS.md the layout, commands, the plugin's conventions, including variant children, request context, add-ons and request reads, and its style; SECURITY.md the scope. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 73 +++++++++++++++------- README.md | 145 ++++++++++++------------------------------- SECURITY.md | 12 ++-- docs/ARCHITECTURE.md | 45 ++++++++++++++ tests/README.md | 57 +++++++++++++++++ 5 files changed, 198 insertions(+), 134 deletions(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 tests/README.md diff --git a/AGENTS.md b/AGENTS.md index c002be3..faaa356 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,22 +1,28 @@ # wordpress-plugin-template -GitHub template for WordPress plugins built on the five wp-framework packages (`ahegyes/wp-framework-{bootstrap,shared,core,settings,woocommerce}`). Each plugin ships its own copy of the framework, PHP-DI and their dependencies, prefixed under `DeepWebSolutions\PluginTemplate\Scoped` by php-scoper through wordpress-configs on every development install, into `vendor-prefixed/`. This file covers what an agent needs beyond the README. +A WordPress plugin built on the five wp-framework packages (`ahegyes/wp-framework-{bootstrap,shared,core,settings,woocommerce}`), from the template that starts every such plugin. It ships its own copy of the framework, PHP-DI and their dependencies, prefixed under `DeepWebSolutions\PluginTemplate\Scoped` by php-scoper through wordpress-configs into `vendor-prefixed/` on every development install. Its architecture follows the framework's conventions 1–10 in [wordpress-framework's AGENTS.md](https://github.com/ahegyes/wordpress-framework/blob/trunk/AGENTS.md); this file covers what is particular to the plugin. `docs/ARCHITECTURE.md` describes the boot, `docs/recipes.md` the common changes, `tests/README.md` the suites. ## Layout -- `dws-plugin-template.php`: the entry. It requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, checks the PHP and WordPress floors, loads `includes/safeguards.php`, then the autoloader and `functions.php`; a missing or partial build, unbuilt scripts included, shows a notice instead of booting. -- `functions.php`: the `dws_tpl_plugin()` accessor, the boot on `plugins_loaded` at priority 15, the deactivation callback, the HPOS declaration (`false` until a plugin's order code is verified) and the loader that requires every `includes/*.php` in name order, except names starting with `_`. -- `src/Plugin.php`: the composition root, with `SLUG`, `PREFIX` (`dws_tpl`) and `COMPONENTS` (the scheduler, admin notices, `Settings`, `Blocks` and `Feature\Feature`). It builds the logger and the scheduler, then, once WooCommerce passes its check, the migration runner over `dws_tpl_get_migration_steps()` and the kernel over a PHP-DI container. `src/` holds only the root and its components; code that registers no hooks lives in `includes/`. -- `src/Settings.php`: an always-on component that adds the WooCommerce settings tab `dws_tpl`, holding the greeting's settings and the uninstall consent. -- `src/Blocks.php`: registers the blocks from `blocks/build/blocks-manifest.php` on `init`, and enqueues `assets/build/editor.js` in the block editor. -- `src/Feature/`: the greeting, a composite gated on `DWS_TPL_OPTION_FEATURE`, whose child greets customers on their account dashboard once their orders reach the set number. -- `includes/footprint.php`: `dws_tpl_get_footprint()`, every kind of data the plugin stores. Uninstall removes it where the site consents, and deactivation unschedules its jobs. -- `includes/options.php`: the `DWS_TPL_OPTION_*` keys, their `DWS_TPL_OPTION_DEFAULTS`, and `dws_tpl_get_bool_option()`, `dws_tpl_get_int_option()`, `dws_tpl_get_string_option()` and `dws_tpl_get_list_option()`, which guard each stored value; `dws_tpl_get_option_text()`, the translated defaults, read after `init`; and the greeting's order-count validator. -- `includes/migrations.php`: `dws_tpl_get_migration_steps()`, the append-only migration steps, whose first stores the settings' untranslated defaults, autoloaded. -- `includes/assets.php`: `dws_tpl_get_asset()`, which reads the `*.asset.php` payload wp-scripts generates beside a built file, and `dws_tpl_register_script()` and `dws_tpl_register_style()`, which register a built file with its dependencies and version. -- `blocks/src/` and `assets/src/`: the TypeScript and SCSS sources of the blocks (`example-notice`), the editor script and the greeting's stylesheet. `npm run build` compiles them into `blocks/build/` and `assets/build/`, which git ignores and the release builds. -- `models/` and `templates/`: empty extension points, for namespace-less classes on Composer's classmap and for template partials. -- `uninstall.php`: runs the framework's uninstall engine over the footprint, with consent from `DWS_TPL_OPTION_DELETE_DATA`, once the floors are met. +``` +dws-plugin-template.php the entry: bootstrap load.php, the updater, the floor check, safeguards, 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 +├── 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 +includes/ hook-free code as dws_tpl_ functions and DWS_TPL_ constants, loaded in name order, _-prefixed files skipped +├── safeguards.php restrictions that hold when the build fails; loaded by the entry before the autoloader +├── 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 +blocks/src/, assets/src/ TypeScript and SCSS, built into blocks/build/ and assets/build/ (untracked) +models/, templates/ empty extension points +.github/scripts/fill-in-scaffold.sh the fill-in's sweep, which fill-in-scaffold.yml runs once in a new plugin and Quality rehearses here +``` ## Commands @@ -25,23 +31,42 @@ composer packages-install # dependencies, then php-scoper into vendor-prefixe composer test:unit composer test:integration # starts .wp-env.tests.json, then runs test:integration:run, which builds the scripts first composer quality-check # manifest and changelog validation, PHPCS (both profiles), PHPStan, Unit and Integration -npm run setup # packages-install for Composer and npm, the scripts' build, then the development environment -npm run build # blocks and assets, through wp-scripts; npm run start watches them -npm run lint:scripts # ESLint, with lint:styles (Stylelint) and lint:types (tsc) beside it +npm run setup # both installs, the scripts' build, then the development site +npm run build # blocks and assets through the local wp-scripts; npm run start watches them in a POSIX shell +npm run lint:scripts # ESLint, beside lint:styles (Stylelint) and lint:types (tsc) npm run test:e2e # Playwright on .wp-env.tests.json, starting it if needed; the scripts must be built ``` +Run wp-scripts only through the npm scripts or `node_modules/.bin/wp-scripts`: `npx wp-scripts` can install an unrelated package of that name. + ## Environments - `.wp-env.json`: development, as `local`, on port 8811, behind `npm run setup` and `wp-env:*`. The framework rethrows boot and migration failures there. -- `.wp-env.tests.json`: Integration and E2E, as production, on port 8812, behind `wp-env:tests:*`, `composer test:integration` and `npm run test:e2e`. The E2E suite deactivates and reactivates WooCommerce there. +- `.wp-env.tests.json`: Integration and end to end, as `production`, on port 8812, behind `wp-env:tests:*`, `composer test:integration` and `npm run test:e2e`. The end-to-end suite deactivates and reactivates WooCommerce there. + +Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI runs Integration and end to end on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. + +## 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` or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. +2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. +3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. +4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. +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), 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, which the footprint covers; anything else gets its own footprint entry. +7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). +8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. -Both run WordPress 7.1.3 on PHP 8.5, with WooCommerce from `vendor/` and HPOS on; CI runs Integration and E2E on every WordPress branch from the entry's `Requires at least` up, and nightly on WordPress trunk. +## Style -## Cache layout +- Every PHP file opens with `:`, never `Co-Authored-By`. -All generated test/build cache under `tests/.cache/`: -- `tests/.cache/phpunit/` — PHPUnit's `cacheDirectory` -- `tests/.cache/artifacts/` — Playwright outputs (storage states, screenshots, test-results) +## Limitations -`.gitignore` has `tests/.cache` in the custom block above the toptal section. +- The fill-in pushes with the workflow's own token, which GitHub refuses for workflow changes, so it never edits `.github/workflows/`; the workflows read the slug from `composer.json`'s `extra.text-domain`. +- Integration and end to end share the tests site, so a row that changes shared data in a separate process flushes the parent's object cache. +- The release zip carries the TypeScript and SCSS sources beside the built files, since a plugin's repository may be private and the zip is then the only place its users find them. diff --git a/README.md b/README.md index d5197f6..970bfcb 100644 --- a/README.md +++ b/README.md @@ -2,128 +2,65 @@ WordPress plugins on the wp-framework packages start here, from one tested anatomy, so all are built alike and a fix found in one applies to all. -## Architecture +The template is itself a working WooCommerce plugin: it boots the framework's composition, holds a settings tab, a feature gated on one of its settings, a block, migrations, safeguards and an uninstall footprint, and every suite and release check passes on it. `docs/ARCHITECTURE.md` describes that anatomy and `docs/recipes.md` the changes most plugins make to it. -``` -dws-plugin-template/ -├── dws-plugin-template.php # WP plugin entry: guards → check_requirements → register_lifecycle_hooks → boot -├── functions.php # Global functions: instance accessor + plugins_loaded boot callback -├── uninstall.php # WP-invoked cleanup → Installer::uninstall() -├── src/ -│ ├── Plugin.php # Singleton implementing PluginInterface: container + kernel + lifecycle -│ ├── Feature/ # GenericFeature (always-on) + WooCommerceFeature (WC-gated) -│ ├── Installer/ # Installer: stored-version I/O, install/update/uninstall -│ ├── Component/ # WelcomeNotice + ExampleWPSettings + ExampleSettings (HookableInterface components) -│ └── Settings/ # ExampleWCSettingsPage (DescriptorBackedWCSettingsPage subclass) -├── config/ -│ ├── container.php # PHP-DI definitions (the composition root) -│ └── footprint.php # Single source of the persistent option/meta keys (container + uninstall fallback) -├── docs/ -│ └── getting-started.md # How the reference boots + how to extend it -├── tests/ -│ ├── bootstrap.php # Composer autoload + (in wp-env) WP load -│ ├── Unit/ # Pure PHP, no Docker (incl. the mock-WP boot smoke) -│ ├── Integration/ # wp-env Docker, real WP loaded -│ └── e2e/ # Playwright + @wordpress/e2e-test-utils-playwright -├── scoper.inc.php # php-scoper config — extends wordpress-configs base -└── dependencies/ # Generated: scoped framework + PHP-DI (gitignored) -``` - -## Fork Setup - -Step 1 in a fork is the **Fill in scaffold** workflow (Actions → "Fill in scaffold" → Run workflow). It takes the plugin's display name, slug, PHP namespace, and wp-env port, then substitutes every placeholder below, renames the entry file, resets the version to `0.1.0` and the changelog to a single Unreleased heading, rewrites the repository-metadata URLs to the fork, and pushes the result back to the ref you ran it on (run it on trunk for the standard flow). The workflow refuses to run on the template repo itself. - -What the workflow can't do — review by hand afterwards: - -- Write real `readme.txt` prose: the workflow resets Description and Tags to TODO placeholders; Contributors stays. -- Adjust the Composer package name when it should follow the `wc-*` / `wp-*` convention instead of the slug — the workflow sets `ahegyes/`. -- wp.org listing assets (banners, screenshots) when publishing there. -- Review the `.github/ISSUE_TEMPLATE/` forms — their wording targets the unmodified template, not a forked plugin — and `SECURITY.md`'s scope prose. -- Prose in `README.md` / `docs/` that describes the example components you replace. - -## Placeholder Convention - -The rename surface the workflow substitutes (the manual list for forks without Actions). Pick the column matching your fork: - -| Placeholder | WC plugin example | Generic WP plugin example | -|------------------------------------------------|----------------------------------------------|--------------------------------------| -| `dws-plugin-template` (slug + text-domain) | `locked-payment-methods-for-woocommerce` | `internal-comments` | -| `DWS_PLUGIN_TEMPLATE` (constants) | `DWS_LPMWC` | `DWS_IC` | -| `dws_plugin_template_` (functions) | `dws_lpmwc_` | `dws_ic_` | -| `dws_plugin_template` (slug / option-key base) | `dws_lpmwc` | `dws_ic` | -| `DeepWebSolutions\PluginTemplate\` (namespace) | `DeepWebSolutions\LockedPaymentMethods\` | `DeepWebSolutions\InternalComments\` | -| `DWS Plugin Template` (display) | `Locked Payment Methods for WooCommerce` | `Internal Comments` | -| `ahegyes/dws-plugin-template` (Composer name) | `ahegyes/wc-locked-payment-methods` | `ahegyes/wp-internal-comments` | -| `8811` (wp-env port) | any free port (avoid 8888/8889) | any free port (avoid 8888/8889) | - -The two underscore forms are distinct. `dws_plugin_template` (bare) is the settings-page slug and the option-key base every persistent row in `config/footprint.php` is built from; `dws_plugin_template_` (trailing) is the function and option-key prefix. Replacing the bare form as a plain substring covers both — the prefix is just the base plus `_` — so renaming only the trailing form leaves the settings options written under the old slug, orphaned on uninstall. - -**WC plugins follow these conventions** (WC trademark policy included): -- **Display**: `X for WooCommerce` (WC trademark requirement). NOT "WooCommerce X" or "WC: X". -- **Slug** + **text-domain**: end in `-for-woocommerce`. -- **Constant + function abbreviations** end in `WC` (`LPMWC` = Locked Payment Methods + WooCommerce). Keeps `DWS_` prefix. -- **Composer package name**: `ahegyes/wc-` (e.g., `ahegyes/wc-locked-payment-methods`). +## Starting a plugin -**Generic WP plugins** drop the `WC` everything: plain slug, plain abbreviation, `wp-` Composer prefix (e.g., `ahegyes/wp-internal-comments`). +Create the plugin's repository from this template with GitHub's **Use this template**, then run the **Fill in scaffold** workflow there (Actions → Fill in scaffold → Run workflow) on the default branch, with these inputs: -The Composer **package** name (`ahegyes/wc-*` / `ahegyes/wp-*`) and the GitHub **repository** name are separate identifiers: the repository keeps the plugin slug (e.g., `github.com/ahegyes/locked-payment-methods-for-woocommerce`), while the Composer name is what `composer.json` declares and other manifests `require`. +| Input | Example | What it sets | +| --- | --- | --- | +| `plugin-name` | `Acme Orders for WooCommerce` | the display name in the plugin header, the strings and the docs | +| `plugin-slug` | `acme-orders-for-woocommerce` | the text domain and the names of the entry file and the release zip | +| `php-namespace` | `AcmeOrders` | the namespace segment after `DeepWebSolutions\` | +| `prefix` | `dws_aowc` | the prefix of every global function, constant, option and hook | +| `wp-env-port` | `8821` | the development site's port; the tests site takes the next one | -**Both variants use a flat namespace** — `DeepWebSolutions\PluginName\` only, no grouping segments between the vendor and the plugin name. +The workflow: +- renames the entry file and replaces the template's names, prefix and ports in every tracked file outside `.github/workflows/`, which read the slug from `composer.json` instead, since GitHub refuses the workflow's own token for workflow changes; +- resets the version to 0.1.0 and the changelog to one unreleased entry; +- makes `README.scaffold.md` the plugin's README, which lists the template's demo and what removing each part takes; +- deletes the disabled example include and refreshes the Composer lock's content hash; +- fails when any name or port of the template is left, and otherwise pushes one commit to the branch it ran on. -The port appears in `.wp-env.json` and `.wp-env.belowfloor.json` (`"port"`), `playwright.config.js` (`WP_BASE_URL`), and this README's "Open localhost" line below — keep them in sync. +## Naming -Scoped deps land under `\Scoped\` inside the plugin's namespace (`DeepWebSolutions\PluginTemplate\Scoped\DI\...`). The `\Scoped\` segment is invariant; renaming the namespace placeholder above is enough. +- 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 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's; a WooCommerce plugin's segment ends in `wc`. +- The namespace is flat: `DeepWebSolutions\\`, with no segment in between. +- The Composer package is `ahegyes/wc-` for a WooCommerce plugin and `ahegyes/wp-` otherwise, while the repository keeps the plugin's slug; the fill-in sets `ahegyes/`, which the author changes by hand. +- Each plugin takes its own pair of wp-env ports, apart from 8888 and 8889, which wp-env uses by default. -## Fork Reset Checklist +## Working on the template -The mechanical reset the workflow performs — the manual list for forks without Actions: +Development needs PHP and Composer at the entry file's `Requires PHP` floor or later, Node.js and npm at `package.json`'s `engines`, Docker for `@wordpress/env`, and a POSIX shell for `npm run start`. -- Reset `2.0.0` → `0.1.0` everywhere (plugin header, `_VERSION` constant, all `@since` / `@version`, `readme.txt` Stable tag, `CHANGELOG.md` heading). Don't touch the SemVer / Keep-a-Changelog URLs. -- Wipe `CHANGELOG.md` body to a single empty `## 0.1.0 - unreleased` block (keep prologue + markers). -- Delete `changelog/*.md` (keep `.gitkeep`). -- Rewrite `readme.txt` Description / Tags / Contributors / etc. -- Repoint every repository-metadata URL at the fork: `composer.json` `homepage` + `authors.homepage`, `package.json` `repository` + `bugs`, `SECURITY.md`'s advisory link, and `readme.txt`'s full-changelog link all reference this template's GitHub repository until reset. +Install the dependencies, prefix the framework into `vendor-prefixed/`, build the scripts and start the development site on : -## Local Development - -Requires: - -- PHP 8.5+. -- Node.js 26+ / npm 11+ (for `@wordpress/env`). -- Docker (for `@wordpress/env`). +```sh +npm run setup +``` -Coverage runs need the `pcov` extension (`pecl install pcov`): CI's unit job collects coverage so the strict coverage-metadata gate enforces, and `composer test:unit -- --coverage-text` does the same locally. Plain test runs don't need it. +Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integration suites on the tests site at : ```sh -composer packages-install # Resolves deps + runs php-scoper → dependencies/ -composer test:unit # Pure PHP unit tests, no Docker - -npm install # Installs @wordpress/env, @playwright/test, etc. -npx playwright install # Downloads browser binaries (one-time, ~150MB) -npm run wp-env:start # Boots WordPress in Docker +composer quality-check +``` -composer test:integration # PHPUnit Integration suite (real WP, no browser) -npm run test:e2e # Playwright E2E suite (browser, real admin UI) +Run the end-to-end suite: -npm run wp-env:stop +```sh +npm run test:e2e ``` -> Use `composer packages-install` / `packages-update` (never bare `composer install` / `update`) — the wrappers pass `--ignore-platform-reqs`, which prevents composer from emitting a `platform_check.php` that would bypass the framework's friendly version-check admin notice. - -`wp-env:start` activates the plugin for you (via the `afterStart` script in `.wp-env.json`). Open and you'll see the "DWS Plugin Template is active." admin notice. +Lint the scripts, styles and types: -CI pins the integration and E2E jobs to the WP floor; reproduce locally with `WP_ENV_CORE=https://wordpress.org/wordpress-7.0.zip npm run wp-env:start`. +```sh +npm run lint:scripts && npm run lint:styles && npm run lint:types +``` -The plugin declares a `dws-plugin-template` text domain and `Domain Path: /languages`. Translation catalogs are generated at release — wp.org builds them for hosted plugins, and forks distributed elsewhere run `wp i18n make-pot . languages/dws-plugin-template.pot` (the shipped `languages/` directory is ready for them). +Every pull request also rehearses the fill-in with sample inputs and gates the filled-in plugin, and rehearses the release on the built zip. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. -## Commands +## License -| Command | What it does | -|-----------------------------------|---------------------------------------------------------| -| `composer test:unit` | PHPUnit Unit suite (no Docker) | -| `composer test:integration` | PHPUnit Integration suite (requires wp-env) | -| `npm run test:e2e` | Playwright E2E suite (requires wp-env) | -| `composer lint:php` | PHPCS + PHPStan | -| `composer format:php` | PHPCBF auto-fix | -| `composer quality-check` | `lint:php` + `test:unit` | -| `composer scope-php-dependencies` | Manual scoping run (auto-runs during `packages-install` / `packages-update`) | +GPL-2.0-or-later. diff --git a/SECURITY.md b/SECURITY.md index d0e0769..7708d50 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,13 @@ ## Reporting a vulnerability -Report privately via [GitHub's security advisory form](https://github.com/ahegyes/wordpress-plugin-template/security/advisories/new). Do not open a public issue. Initial response within 7 days. +Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-plugin-template/security/advisories/new), never in a public issue. The first response comes within 7 days. ## Scope -In scope: -- This template's PHP source, scoping pipeline (`scoper.inc.php`), CI configuration. +In scope: the plugin's PHP, JavaScript and CSS, its prefixing configuration (`scoper.inc.php`) and its CI workflows. -Out of scope: -- Plugins forked from this template — report to the fork's maintainer. -- WordPress core, WooCommerce, or upstream Composer / npm dependencies — report upstream. The transitive `roave/security-advisories` constraint already fails `composer packages-install` on any known CVE in the dep graph. +Out of scope, reported upstream instead: +- plugins generated from this repository, to their maintainers; +- the wp-framework packages, to `ahegyes/wordpress-framework`; +- WordPress, WooCommerce and the other Composer and npm dependencies, to their projects. Composer refuses to resolve a dependency version with a known advisory through `roave/security-advisories`, and the Supply-Chain Audit workflow fails on one. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..1db6f86 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,45 @@ +# Architecture + +This document describes how the plugin loads, boots, stores its data and leaves a site, and where each kind of code lives. + +## Loading + +`dws-plugin-template.php` is the only file WordPress includes, and it parses on PHP 7.4 so that a site below the floors gets a notice instead of a fatal error: + +1. It defines `DWS_TPL_FILE` and registers `languages/`, which WordPress skips for a network-activated plugin. +2. It requires `vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php`, registers the GitHub updater, then checks the `Requires PHP` and `Requires at least` headers. The updater comes first, so a release that lowers the floors still reaches a site below them. On a site below a floor, WordPress's own notice shows and nothing else loads. +3. It loads `includes/safeguards.php`, which holds even when the rest of the build fails. +4. It stops with a notice when the prefixed framework or the built blocks are missing. It then requires the autoloader and `functions.php`; a missing file throws, which local and development environments rethrow and every other environment logs, showing the same notice. + +`functions.php` defines the `dws_tpl_plugin()` accessor, boots the plugin on `plugins_loaded` at priority 15, after WooCommerce and Action Scheduler have loaded, registers the deactivation callback, declares the plugin's WooCommerce feature compatibility, and requires every `includes/*.php` in name order except those whose names start with `_`. + +## Booting + +`src/Plugin.php` is the composition root. Its constructor builds the logger, the scheduler and the framework's `PluginBoot`, which runs the composition and reports its outcome: + +- The scheduler registers its hooks first, so the plugin's recurring jobs keep their schedules while the plugin is blocked. +- `WooCommerceRequirement` checks WooCommerce against the `WC requires at least` header; without it, the boot is `blocked`, `dws_tpl_initialization_blocked` fires, and users who can activate plugins see why the plugin stays off. +- The migration runner registers, and runs pending steps on `wp_loaded`. +- The kernel builds each class in `Plugin::COMPONENTS` through a PHP-DI container and calls its `register_hooks()`; a composite adds its children, and a conditional component loads only when its static `should_load()` returns true. +- Once every component has registered, the boot is `booted` and `dws_tpl_initialized` fires. A throw anywhere in the composition makes it `failed`: `dws_tpl_initialization_failure` fires, the failure is logged, and administrators see a notice, while local and development environments rethrow it. + +The request that activates the plugin does not boot it, because WordPress includes the plugin's file after `plugins_loaded`; its post types, routes and migrations first run on the next request. + +## Where code lives + +- `src/` holds the composition root and its components: final classes that implement the framework's `ComponentInterface` and add their hooks in `register_hooks()`. A component takes ungated services in its constructor, such as the logger, the scheduler or `AdminNotices`, which `Plugin::get_definitions()` provides. +- `includes/` holds code that registers no hooks and is not part of the tree, as prefixed functions and constants: the option keys, defaults and typed readers (`options.php`), the footprint (`footprint.php`), the migration steps (`migrations.php`) and the asset registrars (`assets.php`). Quality's syntax job parses the folder on PHP 7.4, because `safeguards.php` and what it requires run before the floor check passes on a site without the built files. +- `blocks/src/` and `assets/src/` hold the TypeScript and SCSS sources, which `npm run build` compiles into `blocks/build/` and `assets/build/`; `includes/assets.php` reads the `*.asset.php` file wp-scripts writes beside each build, and `src/Blocks.php` registers the blocks from `blocks/build/blocks-manifest.php`. +- `models/` and `templates/` are empty extension points, for classes on Composer's classmap and for template partials. + +## Data + +- **Options** are prefixed constants in `includes/options.php`, read only through its typed readers, which guard each stored value and fall back to the key's default. Translated defaults come from `dws_tpl_get_option_text()` after `init`. +- **Migrations** are the append-only steps `dws_tpl_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_tpl_revision`. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. +- **The footprint**, `dws_tpl_get_footprint()`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Everything under the `dws_tpl_` prefix, or `_dws_tpl_` for hidden meta, is covered; data under another name needs its own entry, which no check enforces. +- **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. +- **Uninstall** runs `uninstall.php`, which calls the framework's uninstall engine over the footprint, with WooCommerce's order meta types, on every site whose `dws_tpl_delete_data` setting consents; a site that never consented keeps its data. Below the floors or with an incomplete build, a web request deletes the files, keeps the data and logs why, while WP-CLI fails and keeps both for a rerun. + +## Prefixing and distribution + +Every development install runs php-scoper through wordpress-configs, which copies the framework, PHP-DI and their dependencies into `vendor-prefixed/` under `DeepWebSolutions\PluginTemplate\Scoped\`, so each plugin carries its own copy and never meets another plugin's. The release workflow builds `dws-plugin-template.zip` from a production install, checks it with Plugin Check, the archive and header checks, activates it on a site and runs the end-to-end suite against it. A pull request rehearses the same build without publishing. A tag publishes a GitHub release, which the bundled updater offers through WordPress's own update check while the `Update URI` header points at the repository. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..46f4627 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,57 @@ +# Tests + +This document describes the plugin's three test suites, the environments they run in and how CI runs them. + +## Suites + +| Suite | Where | Runs on | Covers | +| --- | --- | --- | --- | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage | +| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, and the safeguard recipe | +| End to end | `tests/e2e/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated | + +PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`. + +## Environments + +- `.wp-env.json` is the development site on , as `local`, where the framework rethrows boot and migration failures. +- `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. + +Both start WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. + +## Running + +Run the Unit suite: + +```sh +composer test:unit +``` + +Start the tests site, build the scripts and run the Integration suite: + +```sh +composer test:integration +``` + +Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts: + +```sh +npm run test:e2e +``` + +Run every check CI runs on PHP, Integration included: + +```sh +composer quality-check +``` + +## Continuous integration + +- **Tests** runs Unit, then Integration and the end-to-end suite once per WordPress branch, from the entry file's `Requires at least` up to the newest release, which WordPress's version API lists, so a new release joins without an edit. +- **Tests (nightly)** runs Integration and the end-to-end suite on WordPress trunk every day, outside pull requests. +- **Release** builds the zip on every pull request as a tag would, runs Plugin Check and the archive checks, then activates the zip on a site and runs the end-to-end suite against it. +- **Quality** runs PHPCS, PHPStan, the PHP 7.4 parse of the entry file, `uninstall.php` and `includes/`, the script, style and type lints, the `block.json` schema and the changelog fragments. + +## Conventions + +Test classes are `final`, named after the class or the behavior they cover with a `Test` suffix, with `test_snake_case` methods, kebab-case data provider keys and no docblocks. They use real instances and WordPress's own APIs, never mocks of the code under test. From 466fa8452d113ab01df241b6d9d2fef2b098a52c Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:15:10 +0100 Subject: [PATCH 05/15] Pin the release's version-check fix and setup-php 2.40.0 The release's built-zip test compared two empty strings, because wp-env's run consumed the -- before bash -c's arguments, so it passed for any version; wordpress-configs' fix/release-version-check branch fixes it and moves its workflows to setup-php 2.40.0. The template's own jobs still pinned setup-php 2.37.2, which Dependabot proposed bumping in one of them only. Every wordpress-configs workflow pin moves to that branch's head, 3f49ee7, so this pull request's release dry run checks the fix, and the template's three setup-php pins move to 2.40.0. package.json's configs dependency and both locks follow once the branch is on GitHub. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/audit.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/fill-in-scaffold.yml | 2 +- .github/workflows/quality.yml | 12 ++++++------ .github/workflows/release.yml | 2 +- .github/workflows/tests-nightly.yml | 4 ++-- .github/workflows/tests.yml | 6 +++--- .github/workflows/workflow-checks.yml | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 5e1ab49..b443e2c 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -16,6 +16,6 @@ jobs: audit: name: Audit # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. - uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@3f49ee71edc29034e413446e8f214720201115a3 with: npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 06ffa3e..4b44677 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,4 +20,4 @@ jobs: actions: read contents: read security-events: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@3f49ee71edc29034e413446e8f214720201115a3 diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 7743fe3..af57e74 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -42,7 +42,7 @@ jobs: with: persist-credentials: true - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.5' coverage: none diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 07f781a..5d9c46f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,20 +11,20 @@ permissions: jobs: lint-php: name: PHP Lint - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@3f49ee71edc29034e413446e8f214720201115a3 with: php-version: '8.5' scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan"]' lint-scripts-styles: name: Scripts and styles lint - uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@3f49ee71edc29034e413446e8f214720201115a3 with: scripts: '["lint:scripts", "lint:styles", "lint:types"]' block-json: name: block.json schema - uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@3f49ee71edc29034e413446e8f214720201115a3 slug: name: Plugin slug @@ -50,7 +50,7 @@ jobs: php-syntax: name: PHP syntax needs: slug - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@3f49ee71edc29034e413446e8f214720201115a3 with: php-versions: '["7.4"]' paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }} @@ -62,7 +62,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.5' coverage: none @@ -80,7 +80,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # 2.37.2 + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.5' coverage: none diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 75e856d..4bd6509 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: permissions: actions: read contents: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@3f49ee71edc29034e413446e8f214720201115a3 with: project-path: '.' plugin-slug: ${{ needs.slug.outputs.slug }} diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml index 2497c07..1bf5483 100644 --- a/.github/workflows/tests-nightly.yml +++ b/.github/workflows/tests-nightly.yml @@ -12,7 +12,7 @@ permissions: jobs: integration: name: Integration - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 with: php-version: '8.5' wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' @@ -21,7 +21,7 @@ jobs: e2e: name: E2E - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3f49ee71edc29034e413446e8f214720201115a3 with: artifact-slug: nightly php-version: '8.5' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ba2845e..e039d81 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ permissions: jobs: unit: name: Unit - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 with: php-version: '8.5' composer-script: 'test:unit' @@ -52,7 +52,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -66,7 +66,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3f49ee71edc29034e413446e8f214720201115a3 with: artifact-slug: wp-${{ matrix.wp }} php-version: '8.5' diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index 7c13bc7..e385c29 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -24,4 +24,4 @@ jobs: contents: read security-events: write actions: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@3f49ee71edc29034e413446e8f214720201115a3 From 9a855f574494c1eb860f4c09a243308605809a2d Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:25:20 +0100 Subject: [PATCH 06/15] Refuse fill-in inputs the plugin cannot boot or sweep with The sweep accepted a prefix the framework's admin notices refuse, such as dws_network or one over 32 characters, so the filled-in plugin threw at composition. A port of 8812 became the tests port's 8813 through the second substitution, and both environments shared it. The plugin also kept the template's recipe test, which redeclares the recipe's safeguard helper once a plugin applies the recipe. The sweep refuses those prefixes and any port that puts either environment on 8811 or 8812, replaces both ports in one pass, and deletes the recipe test. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 16 ++++++++++------ README.md | 4 ++-- tests/README.md | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 8f8dd96..2fa4d33 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -8,10 +8,12 @@ set -euo pipefail # The slug names the entry file and the text domain, so it stays clean kebab-case. grep -Eq '^[a-z][a-z0-9]*(-[a-z0-9]+)*$' <<< "$SLUG" || { echo "::error::plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen."; exit 1; } grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace must match ^[A-Za-z][A-Za-z0-9]*$."; exit 1; } -# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's. -grep -Eq '^dws_[a-z0-9]+$' <<< "$PREFIX" || { echo "::error::prefix must match ^dws_[a-z0-9]+$."; exit 1; } -# Both ports land in JSON numbers, where a leading zero is invalid, and the tests environment takes the next one. -{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65534 ]; } || { echo "::error::wp-env-port must be 1-65534 with no leading zero."; exit 1; } +# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's, and the +# framework's admin notices refuse a prefix over 32 characters or with a network or hidden segment. +{ grep -Eq '^dws_[a-z0-9]{1,28}$' <<< "$PREFIX" && [ "$PREFIX" != dws_network ] && [ "$PREFIX" != dws_hidden ]; } || { echo "::error::prefix must match ^dws_[a-z0-9]{1,28}$ and must not be dws_network or dws_hidden."; exit 1; } +# Both ports land in JSON numbers, where a leading zero is invalid, and the tests environment takes the next one; +# neither may be the template's own 8811 or 8812, which the leftover check below refuses. +{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65534 ] && { [ "$PORT" -lt 8810 ] || [ "$PORT" -gt 8812 ]; }; } || { echo "::error::wp-env-port must be 1-65534 with no leading zero, and neither it nor the next port may be 8811 or 8812."; exit 1; } # The display name lands in single-quoted PHP and JavaScript strings. if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/]"; then echo "::error::plugin-name must not contain \\, \$, / or '."; exit 1; fi @@ -21,6 +23,9 @@ export REPO_NAME="${REPO#*/}" git mv --force README.scaffold.md README.md 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. +git rm --quiet tests/Integration/SafeguardRecipeTest.php git mv dws-plugin-template.php "$SLUG.php" # GitHub refuses a GITHUB_TOKEN push that changes a workflow, so the workflows read the slug from composer.json @@ -41,8 +46,7 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold. # shellcheck disable=SC2016 git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json' | xargs -0 perl -pi -e ' BEGIN { @ARGV = grep { ! -l $_ } @ARGV } - s/\b8811\b/$ENV{PORT}/g; - s/\b8812\b/$ENV{TESTS_PORT}/g; + s/\b(881[12])\b/8811 == $1 ? $ENV{PORT} : $ENV{TESTS_PORT}/ge; s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/; ' # shellcheck disable=SC2016 diff --git a/README.md b/README.md index 970bfcb..a30d9c9 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,10 @@ 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 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's; a WooCommerce plugin's segment ends in `wc`. +- The prefix is `dws_` and one segment of up to 28 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's; 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 Composer package is `ahegyes/wc-` for a WooCommerce plugin and `ahegyes/wp-` otherwise, while the repository keeps the plugin's slug; the fill-in sets `ahegyes/`, which the author changes by hand. -- Each plugin takes its own pair of wp-env ports, apart from 8888 and 8889, which wp-env uses by default. +- Each plugin takes its own pair of wp-env ports, apart from 8888 and 8889, which wp-env uses by default, and the template's own 8811 and 8812. ## Working on the template diff --git a/tests/README.md b/tests/README.md index 46f4627..0391659 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,7 +7,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | | Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage | -| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, and the safeguard recipe | +| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, and the assets and blocks | | End to end | `tests/e2e/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated | PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`. From b8c87e296b41679c709a72197b2e577c548f735e Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:25:20 +0100 Subject: [PATCH 07/15] State what the footprint covers and who sees a site notice The docs said the footprint covered everything under the plugin's prefix, hidden meta included, but meta on posts, terms, comments and orders goes only where a meta_prefixes entry names its type. The notice recipe sent a site notice to every administrator with a scope and a capability alone, where the first viewing consumes it for everyone. The docs name what the prefix covers and the meta_prefixes entry, and the notice recipe adds per_viewer. Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/recipes.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index faaa356..559a291 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,7 +53,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 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. 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), 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, which the footprint covers; anything else gets its own footprint entry. +6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. 7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1db6f86..118be5a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,7 +36,7 @@ The request that activates the plugin does not boot it, because WordPress includ - **Options** are prefixed constants in `includes/options.php`, read only through its typed readers, which guard each stored value and fall back to the key's default. Translated defaults come from `dws_tpl_get_option_text()` after `init`. - **Migrations** are the append-only steps `dws_tpl_get_migration_steps()` returns. The framework's runner runs each pending step once per site on `wp_loaded`, behind a lock, and records the revision reached in `dws_tpl_revision`. A step converges from any partial state and throws when a write fails, so a failed step runs again on a later request. -- **The footprint**, `dws_tpl_get_footprint()`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Everything under the `dws_tpl_` prefix, or `_dws_tpl_` for hidden meta, is covered; data under another name needs its own entry, which no check enforces. +- **The footprint**, `dws_tpl_get_footprint()`, names every kind of data the plugin stores: option and user meta prefixes, network options, scheduled hooks and Action Scheduler groups. Options, transients and user meta under the `dws_tpl_` prefix are covered; meta on posts, terms, comments or orders needs a `meta_prefixes` entry per object type, and data under another name its own entry, which no check enforces. - **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. - **Uninstall** runs `uninstall.php`, which calls the framework's uninstall engine over the footprint, with WooCommerce's order meta types, on every site whose `dws_tpl_delete_data` setting consents; a site that never consented keeps its data. Below the floors or with an incomplete build, a web request deletes the files, keeps the data and logs why, while WP-CLI fails and keeps both for a rerun. diff --git a/docs/recipes.md b/docs/recipes.md index 9c18fb8..0a79730 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,6 +1,6 @@ # Recipes -These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces; anything stored under the plugin's `dws_tpl_` prefix, or `_dws_tpl_` for hidden meta, is in the footprint already. +These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces. Options, transients and user meta under the plugin's `dws_tpl_` prefix are in the footprint already; meta on posts, terms, comments or orders needs its own `meta_prefixes` entry per object type. - [A safeguard](#a-safeguard) - [A one-time notice](#a-one-time-notice) @@ -194,7 +194,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin } ``` -The notice's scope defaults to the current user and its message is HTML, so escape it. A notice for every administrator of the site takes `NoticeScope::Site` and a capability; one that stays until dismissed takes `persistent: true`. +The notice's scope defaults to the current user and its message is HTML, so escape it. A notice for every administrator of the site takes `NoticeScope::Site`, a capability and `per_viewer: true`, since without it the first administrator's viewing consumes it for everyone; one that stays until dismissed takes `persistent: true`. ## A WordPress settings page @@ -407,7 +407,7 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI } ``` -Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box, and `_dws_tpl_` meta is in the footprint's family; declare it for uninstall in `includes/footprint.php`: +Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. The leading underscore hides the key from WordPress's Custom Fields box. Post meta is not in the footprint until its type is, so declare the prefix for uninstall in `includes/footprint.php`: ```php 'meta_prefixes' => array( 'post' => array( '_' . Plugin::PREFIX . '_' ) ), From e91ae8d818953316ff7842f628f1f9cef80f8fd6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:30:28 +0100 Subject: [PATCH 08/15] Complete the steps for removing the greeting and the example block Following the scaffold README's removal steps broke the plugin: the greeting's steps left Feature in Plugin::COMPONENTS, whose missing class the kernel throws on, and its section in the settings tab, and the block's steps left build and start calling the deleted scripts and Blocks registering blocks from the deleted manifest on every request. Each row now names those entries too, and PluginBootTest's expectation that the greeting is skipped. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.scaffold.md b/README.scaffold.md index d4d6954..804be26 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -52,8 +52,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/`, `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`. `tests/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks`, points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `BlocksTest`'s block rows. | +| 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/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, and `BlocksTest`'s editor row; 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. | | 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. | From 02501a77cfcf520d8ad54ebecb8f935ef0db3ae5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:33:15 +0100 Subject: [PATCH 09/15] Name the lint paths a plugin without blocks drops The block-removal row left lint:scripts naming blocks/src, so ESLint failed on the missing path and Quality went red for a plugin that followed it; lint:styles failed the same way once the greeting's SCSS was gone too. The row names blocks/src in both scripts. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.scaffold.md b/README.scaffold.md index 804be26..2d0a18e 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/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `BlocksTest`'s block rows. | +| The example block | `blocks/src/example-notice/`, registered by `src/Blocks.php` from `blocks/build/blocks-manifest.php` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, and `blocks/src` from `lint:scripts`, whose ESLint fails on a missing path, and from `lint:styles`, whose Stylelint fails once no SCSS file is left at all. It then points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, and `BlocksTest`'s editor row; 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. | | 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. | From b65ae2d6e502cd91491df1990d07ddfb39858c0c Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:37:37 +0100 Subject: [PATCH 10/15] Complete the steps for removing the editor script and the empty folders The editor-script row left assets/src in lint:scripts, where ESLint fails once the folder holds no script, the editor hook in Blocks, and Blocks in PluginBootTest's resolved components once Blocks goes; the models/ row left models/ in composer.json's classmap, so Composer failed to dump the autoloader. Each row names those entries. The block, editor-script, models/ and templates/ rows applied together leave a plugin that installs, lints, builds and passes Unit. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.scaffold.md b/README.scaffold.md index 2d0a18e..0f959cd 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 | --- | --- | --- | | 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/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, and `blocks/src` from `lint:scripts`, whose ESLint fails on a missing path, and from `lint:styles`, whose Stylelint fails once no SCSS file is left at all. It then points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, and `BlocksTest`'s editor row; 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. | +| 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`, `assets/src` from `lint:scripts`, whose ESLint fails on a path holding no script, 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`, `BlocksTest`, and `Blocks` in `PluginBootTest`'s resolved components. | +| `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`, since Composer fails to dump the autoloader for 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. | Still to decide by hand: From a5eb834fa866875cecb17eb27b40f99ec0ce5383 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:40:15 +0100 Subject: [PATCH 11/15] Say a custom-rendered field needs its own validator The product-tab and edit-screen recipes described a field with its own render callable without its validate callable, and the framework refuses to save a custom control's value that nothing validates, so such a field rejected every submission and kept its old value. Both recipes say the field takes a validate callable that returns the value to store or a WP_Error. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index 0a79730..50ae9bc 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -413,7 +413,7 @@ Read the stored value with `ProductMeta::get_value( $product, '_dws_tpl_box' )`. 'meta_prefixes' => array( 'post' => array( '_' . Plugin::PREFIX . '_' ) ), ``` -A field with its own `render` callable returns its control as inline markup, escapes, names and describes it, and prints a hidden empty input before it unless the field is disabled. Without that input, a cleared control posts nothing, and the save leaves the stored value untouched. +A field with its own `render` callable returns its control as inline markup, escapes, names and describes it, and prints a hidden empty input before it unless the field is disabled. Without that input, a cleared control posts nothing, and the save leaves the stored value untouched. It also takes a `validate` callable that returns the value to store or a `WP_Error`, since the framework refuses to save a custom control's value that nothing validates. ## A form on an edit screen @@ -493,7 +493,7 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac } ``` -The host checks that the user can edit the post itself, and the form's `capability` is one more capability the user needs, checked without an object, so it names a primitive capability such as `edit_posts` rather than `edit_post`. The form verifies its own nonce before it asks for the definition on a save. Declare the meta for uninstall in `includes/footprint.php`, as the product data tab does. A field with its own `render` callable prints a hidden empty input before its control unless the field is disabled, for the reason that recipe gives. +The host checks that the user can edit the post itself, and the form's `capability` is one more capability the user needs, checked without an object, so it names a primitive capability such as `edit_posts` rather than `edit_post`. The form verifies its own nonce before it asks for the definition on a save. Declare the meta for uninstall in `includes/footprint.php`, as the product data tab does. A field with its own `render` callable prints a hidden empty input before its control unless the field is disabled, and takes a `validate` callable, for the reasons that recipe gives. ## A section on WooCommerce's Payments tab From 0baa8a4326d7c78202ded5cd86fe1bf2eb2ef105 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:43:17 +0100 Subject: [PATCH 12/15] Install Playwright's browser before the first end-to-end run The setup steps never installed a browser, and neither npm run setup nor the Playwright configuration downloads one, so on a fresh machine npm run test:e2e failed for a missing Chromium. README.md, README.scaffold.md and tests/README.md install Chromium, the browser the configuration runs, once before the suite. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 3 ++- README.scaffold.md | 3 ++- tests/README.md | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a30d9c9..9b55319 100644 --- a/README.md +++ b/README.md @@ -47,9 +47,10 @@ Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integrat composer quality-check ``` -Run the end-to-end suite: +Install the browser the end-to-end suite runs in, once per machine, then run the suite: ```sh +npx playwright install chromium npm run test:e2e ``` diff --git a/README.scaffold.md b/README.scaffold.md index 0f959cd..e5ee078 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -32,9 +32,10 @@ Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integrat composer quality-check ``` -Run the end-to-end suite against the tests site: +Install the browser the end-to-end suite runs in, once per machine, then run the suite against the tests site: ```sh +npx playwright install chromium npm run test:e2e ``` diff --git a/tests/README.md b/tests/README.md index 0391659..9093fa8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -33,9 +33,10 @@ Start the tests site, build the scripts and run the Integration suite: composer test:integration ``` -Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts: +Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and, once per machine, Playwright's Chromium: ```sh +npx playwright install chromium npm run test:e2e ``` From ee05df098c9ff118adfd780d92e3cd8ea349f322 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:45:56 +0100 Subject: [PATCH 13/15] Repoint the build gate when the editor script goes The block-removal row's example points the entry's build gate at assets/build/editor.asset.php, and the editor-script row then deleted the entry that writes it, so the gate found no build and the plugin never booted. The editor-script row points the gate at a file the build still writes, and drops the gate's built-file check once nothing is built. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.scaffold.md b/README.scaffold.md index e5ee078..a82ca07 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -55,7 +55,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas | --- | --- | --- | | 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/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, and `blocks/src` from `lint:scripts`, whose ESLint fails on a missing path, and from `lint:styles`, whose Stylelint fails once no SCSS file is left at all. It then points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, `assets/src` from `lint:scripts`, whose ESLint fails on a path holding no script, 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`, `BlocksTest`, and `Blocks` in `PluginBootTest`'s resolved components. | +| 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`, `assets/src` from `lint:scripts`, whose ESLint fails on a path holding no script, and `BlocksTest`'s editor row. Where the entry's build gate names `assets/build/editor.asset.php`, point it at a file the build still writes, such as `assets/build/greeting.asset.php`, since the build cleans the editor's files from its output. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`, and drop the gate's check of a built file, since nothing is built. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS`, `BlocksTest`, and `Blocks` in `PluginBootTest`'s resolved components. | | `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`, since Composer fails to dump the autoloader for 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. | From a8231ba2b8faa066d13eea09af80e513f3038b38 Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 20:51:23 +0100 Subject: [PATCH 14/15] State once the settings that follow whatever demo parts remain Each removal row named the build gate and lint paths its own removal touched, so following two rows in turn broke what the first had set: the editor row repointed the gate at the greeting's build, which the greeting row deletes, and removing every stylesheet left lint:styles matching nothing, which Stylelint refuses. The rows name only their own files, and four rules after the table cover any combination: the gate follows the remaining build, the lint scripts name only folders that still hold files, lint:styles keeps a pattern with --allow-empty-input once no SCSS is left, and build stays for its callers. A plugin left with nothing to build installs, lints and passes Unit under them. Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.scaffold.md b/README.scaffold.md index a82ca07..d21ded2 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -54,11 +54,18 @@ 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/e2e/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` | The entry's build gate requires that manifest, so a plugin with no blocks deletes `blocks/`, `build:blocks` and `start:blocks` with their calls in `build` and `start`, `register_blocks()` and its `init` hook in `src/Blocks.php`, and `blocks/src` from `lint:scripts`, whose ESLint fails on a missing path, and from `lint:styles`, whose Stylelint fails once no SCSS file is left at all. It then points the gate at a file its own build writes, such as `assets/build/editor.asset.php`, and drops `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`, `assets/src` from `lint:scripts`, whose ESLint fails on a path holding no script, and `BlocksTest`'s editor row. Where the entry's build gate names `assets/build/editor.asset.php`, point it at a file the build still writes, such as `assets/build/greeting.asset.php`, since the build cleans the editor's files from its output. Once `assets/src/` holds nothing, delete `build:assets` and `start:assets` with their calls in `build` and `start`, and drop the gate's check of a built file, since nothing is built. Once the plugin registers no blocks either, delete `src/Blocks.php`, its row in `Plugin::COMPONENTS`, `BlocksTest`, and `Blocks` in `PluginBootTest`'s resolved components. | +| 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`, `BlocksTest`, and `Blocks` in `PluginBootTest`'s resolved components. | | `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`, since Composer fails to dump the autoloader for 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. | +Whichever parts go, four settings follow what is left, since a plugin removes them in any order: + +- The entry's build gate checks `blocks/build/blocks-manifest.php`, and the plugin stays off until that file exists, so point it at a file the remaining build writes, such as `assets/build/editor.asset.php`, or drop its built-file check once nothing is built. +- `lint:scripts` names only folders that still hold a script, since ESLint fails on a path that holds none. +- `lint:styles` names only folders that still hold SCSS. Once none does, it keeps one pattern, such as `'assets/src/**/*.scss'`, and takes `--allow-empty-input`, since Stylelint fails when nothing matches, Quality runs it, and without a pattern wp-scripts lints every stylesheet in the project. +- `build` stays, even once it runs nothing, since `npm run setup` and the Integration run call it. + Still to decide by hand: - The Composer package name: the fill-in set `ahegyes/dws-plugin-template`, while plugins published to Composer use `ahegyes/wc-` for WooCommerce and `ahegyes/wp-` otherwise. From 7a46f2d226fe833b3797aa849ad716cfeda77cfb Mon Sep 17 00:00:00 2001 From: Tony Hegyes Date: Wed, 28 Oct 2026 21:29:50 +0100 Subject: [PATCH 15/15] Pin wordpress-configs at its trunk for every workflow and tool The workflows pinned the version-check fix's branch head, 3f49ee7, so this pull request's release dry run would check the fix before it merged, while the npm tooling and the Composer lock stayed on a6a7073. All of them pin 9f64dc7, configs' trunk, which merges that branch. Since a6a7073, configs changed only its workflows and their tests, so the npm package and the Composer package behave as before. Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/audit.yml | 2 +- .github/workflows/codeql.yml | 2 +- .github/workflows/quality.yml | 8 ++++---- .github/workflows/release.yml | 2 +- .github/workflows/tests-nightly.yml | 4 ++-- .github/workflows/tests.yml | 6 +++--- .github/workflows/workflow-checks.yml | 2 +- composer.lock | 8 ++++---- package-lock.json | 4 ++-- package.json | 2 +- 10 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index b443e2c..212883a 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -16,6 +16,6 @@ jobs: audit: name: Audit # npm gate audits production deps only — the dev-tooling graph (wp-scripts/playwright trees) is advisory via dependabot alerts. - uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4b44677..18ef77a 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -20,4 +20,4 @@ jobs: actions: read contents: read security-events: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@9f64dc77b25727130884eaf427d2bd75493557ce diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 5d9c46f..d0e126d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,20 +11,20 @@ permissions: jobs: lint-php: name: PHP Lint - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' scripts: '["lint:php:phpcs", "lint:php:phpcs:tests", "lint:php:phpstan"]' lint-scripts-styles: name: Scripts and styles lint - uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: scripts: '["lint:scripts", "lint:styles", "lint:types"]' block-json: name: block.json schema - uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@9f64dc77b25727130884eaf427d2bd75493557ce slug: name: Plugin slug @@ -50,7 +50,7 @@ jobs: php-syntax: name: PHP syntax needs: slug - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-versions: '["7.4"]' paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bd6509..26162f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: permissions: actions: read contents: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: project-path: '.' plugin-slug: ${{ needs.slug.outputs.slug }} diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml index 1bf5483..211133d 100644 --- a/.github/workflows/tests-nightly.yml +++ b/.github/workflows/tests-nightly.yml @@ -12,7 +12,7 @@ permissions: jobs: integration: name: Integration - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' wp-env-core: 'https://wordpress.org/nightly-builds/wordpress-latest.zip' @@ -21,7 +21,7 @@ jobs: e2e: name: E2E - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: artifact-slug: nightly php-version: '8.5' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e039d81..752b63f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ permissions: jobs: unit: name: Unit - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' composer-script: 'test:unit' @@ -52,7 +52,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -66,7 +66,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: artifact-slug: wp-${{ matrix.wp }} php-version: '8.5' diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index e385c29..dc6460d 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -24,4 +24,4 @@ jobs: contents: read security-events: write actions: read - uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@3f49ee71edc29034e413446e8f214720201115a3 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@9f64dc77b25727130884eaf427d2bd75493557ce diff --git a/composer.lock b/composer.lock index 99df16b..f148f46 100644 --- a/composer.lock +++ b/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "a6a70735ba7a94628011b9856bf99159c490f7b5" + "reference": "9f64dc77b25727130884eaf427d2bd75493557ce" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/a6a70735ba7a94628011b9856bf99159c490f7b5", - "reference": "a6a70735ba7a94628011b9856bf99159c490f7b5", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/9f64dc77b25727130884eaf427d2bd75493557ce", + "reference": "9f64dc77b25727130884eaf427d2bd75493557ce", "shasum": "" }, "require": { @@ -139,7 +139,7 @@ "source": "https://github.com/ahegyes/wordpress-configs/tree/trunk", "issues": "https://github.com/ahegyes/wordpress-configs/issues" }, - "time": "2026-10-28T16:59:30+00:00" + "time": "2026-10-28T20:26:01+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", diff --git a/package-lock.json b/package-lock.json index f530d19..84d0c10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "name": "@ahegyes/dws-plugin-template", "license": "GPL-2.0-or-later", "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#a6a70735ba7a94628011b9856bf99159c490f7b5", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.64.1", "@types/react": "^19.3.0", @@ -25,7 +25,7 @@ }, "node_modules/@ahegyes/wordpress-configs": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#a6a70735ba7a94628011b9856bf99159c490f7b5", + "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", "integrity": "sha512-Yk/hpkF7NQoZHAgDgByPOV5zENW9oaeb58MdX9fcOKlnlJs0C+lwI7DLBrVVmzNzBNrLkBCO6hVPoYH8/xeFhw==", "dev": true, "license": "MIT", diff --git a/package.json b/package.json index ac80097..4095178 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "npm": ">=11" }, "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#a6a70735ba7a94628011b9856bf99159c490f7b5", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.64.1", "@types/react": "^19.3.0",