diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh new file mode 100755 index 0000000..2fa4d33 --- /dev/null +++ b/.github/scripts/fill-in-scaffold.sh @@ -0,0 +1,81 @@ +#!/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, 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 + +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 +# 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 +# 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/\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 +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/audit.yml b/.github/workflows/audit.yml index 5e1ab49..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@a6a70735ba7a94628011b9856bf99159c490f7b5 + 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 06ffa3e..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@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@9f64dc77b25727130884eaf427d2bd75493557ce diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index dbb092a..af57e74 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@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + 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..d0e126d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,27 +11,49 @@ 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@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@a6a70735ba7a94628011b9856bf99159c490f7b5 + 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@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@9f64dc77b25727130884eaf427d2bd75493557ce + + 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 - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + needs: slug + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@9f64dc77b25727130884eaf427d2bd75493557ce 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 @@ -40,10 +62,41 @@ 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 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@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + 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..26162f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,16 +14,38 @@ 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 contents: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@9f64dc77b25727130884eaf427d2bd75493557ce 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-nightly.yml b/.github/workflows/tests-nightly.yml index 2497c07..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@a6a70735ba7a94628011b9856bf99159c490f7b5 + 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@a6a70735ba7a94628011b9856bf99159c490f7b5 + 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 b436825..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@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' composer-script: 'test:unit' @@ -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/) @@ -51,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@9f64dc77b25727130884eaf427d2bd75493557ce with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -65,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@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 7c13bc7..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@a6a70735ba7a94628011b9856bf99159c490f7b5 + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@9f64dc77b25727130884eaf427d2bd75493557ce diff --git a/AGENTS.md b/AGENTS.md index c002be3..559a291 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. 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. -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..9b55319 100644 --- a/README.md +++ b/README.md @@ -2,128 +2,66 @@ 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 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, and the template's own 8811 and 8812. -## 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) +Install the browser the end-to-end suite runs in, once per machine, then run the suite: -npm run wp-env:stop +```sh +npx playwright install chromium +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/README.scaffold.md b/README.scaffold.md new file mode 100644 index 0000000..d21ded2 --- /dev/null +++ b/README.scaffold.md @@ -0,0 +1,73 @@ +# 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 +``` + +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 +``` + +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/` 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` | 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. +- 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. 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/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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..118be5a --- /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. 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. + +## 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/docs/recipes.md b/docs/recipes.md new file mode 100644 index 0000000..50ae9bc --- /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. 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) +- [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`, 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 + +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. 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 . '_' ) ), +``` + +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 + +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, and takes a `validate` callable, for the reasons 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. 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", 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; + } + } +} diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..9093fa8 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,58 @@ +# 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, 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/`. + +## 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 and, once per machine, Playwright's Chromium: + +```sh +npx playwright install chromium +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.