diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 165ab26..740352c 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -3,19 +3,39 @@ # generated from the template, and Quality rehearses it on the template with sample inputs. set -euo pipefail -: "${PLUGIN_NAME:?}" "${SLUG:?}" "${NS:?}" "${PREFIX:?}" "${PORT:?}" "${REPO:?}" +: "${PLUGIN_NAME:?}" "${DESCRIPTION:?}" "${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; } +fail() { + echo "::error::$1" + exit 1 +} + +# Each input lands on one line of a PHP, JSON or Markdown file, where a control character breaks it, so the checks +# below match whole values. +for input in "$PLUGIN_NAME" "$DESCRIPTION" "$SLUG" "$NS" "$PREFIX" "$PORT"; do + [[ $input != *[[:cntrl:]]* ]] || fail 'No input may contain a line break or another control character.' +done + +# The slug names the entry file, the text domain and the block namespace, which WordPress refuses unless it starts +# with a letter, and the workflows check the slug they read from composer.json with the same pattern. +[[ $SLUG =~ ^[a-z][a-z0-9]*(-[a-z0-9]+)*$ ]] || fail 'plugin-slug must be kebab-case starting with a letter, with no leading, trailing or double hyphen.' +# A namespace that is a reserved word, such as List, parses from PHP 8.0 only, which the fill-in job's PHP 7.4 syntax +# check refuses. +[[ $NS =~ ^[A-Za-z][A-Za-z0-9]*$ ]] || fail 'php-namespace must match ^[A-Za-z][A-Za-z0-9]*$.' +# One segment after dws_ keeps every prefix in one plugin's family from starting another plugin's; past 23 characters +# the template's longest option constants fail PHPCS's array alignment, which Quality's rehearsal at the cap checks, +# and the framework's admin notices refuse a network or hidden segment. +[[ $PREFIX =~ ^dws_[a-z0-9]{1,23}$ && $PREFIX != dws_network && $PREFIX != dws_hidden ]] || fail 'prefix must match ^dws_[a-z0-9]{1,23}$ and must not be dws_network or dws_hidden.' # The ports land in JSON numbers, where a leading zero is invalid, and the tests and multisite environments take the # next two; none may be the template's own 8811, 8812 or 8813, which the leftover check below refuses. -{ grep -Eq '^[1-9][0-9]{0,4}$' <<< "$PORT" && [ "$PORT" -le 65533 ] && { [ "$PORT" -lt 8809 ] || [ "$PORT" -gt 8813 ]; }; } || { echo "::error::wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813."; exit 1; } -# The display name lands in single-quoted PHP and JavaScript strings, and in JSX text. -if printf '%s' "$PLUGIN_NAME" | grep -q "['\\\$/<>{}]"; then echo "::error::plugin-name must not contain \\, \$, /, ', <, >, { or }."; exit 1; fi +[[ $PORT =~ ^[1-9][0-9]{0,4}$ && $PORT -le 65533 && ( $PORT -lt 8809 || $PORT -gt 8813 ) ]] || + fail 'wp-env-port must be 1-65533 with no leading zero, and neither it nor the next two ports may be 8811, 8812 or 8813.' +# WordPress skips a plugin whose name is blank, and the name lands in single-quoted PHP and JavaScript strings, and in +# JSX text. +name_breakers="['\\\$/<>{}]" +[[ $PLUGIN_NAME =~ [^[:space:]] && ! $PLUGIN_NAME =~ $name_breakers ]] || fail "plugin-name must not be blank or contain \\, \$, /, ', <, >, { or }." +# The description lands in the entry's header docblock, which */ would close. +[[ $DESCRIPTION =~ [^[:space:]] && $DESCRIPTION != *'*/'* ]] || fail 'description must not be blank or contain */.' export SCREAMING_PREFIX="${PREFIX^^}" export TESTS_PORT=$(( PORT + 1 )) @@ -23,6 +43,8 @@ export MULTISITE_PORT=$(( PORT + 2 )) export REPO_NAME="${REPO#*/}" # Composer and npm accept only lowercase package names, which the sweep takes from the repository. export REPO="${REPO,,}" +TEMPLATE_DESCRIPTION=$(php -r 'echo json_decode( (string) file_get_contents( "composer.json" ), false, 512, JSON_THROW_ON_ERROR )->description;') +export TEMPLATE_DESCRIPTION git mv --force README.scaffold.md README.md # The scaffold README becomes the plugin's README.md, which .distignore already excludes. @@ -52,11 +74,17 @@ git ls-files -z -- . ':!.github/workflows/' ':!.github/scripts/fill-in-scaffold. 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[123])\b/8811 == $1 ? $ENV{PORT} : 8812 == $1 ? $ENV{TESTS_PORT} : $ENV{MULTISITE_PORT}/ge; - s/^(\s*\*\s*\@(?:since|version)\s+)2\.0\.0$/${1}0.1.0/; ' + +# The description goes in after the port sweep, which would rewrite a port number in it. +# shellcheck disable=SC2016 +perl -pi -e 's/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$ENV{DESCRIPTION}/' "$SLUG.php" +# The manifests hold the description in JSON strings, so their copy escapes quotes and backslashes. # 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 +perl -pi -e 'BEGIN { ( $description = $ENV{DESCRIPTION} ) =~ s/(["\\])/\\$1/g } s/\Q$ENV{TEMPLATE_DESCRIPTION}\E/$description/' composer.json package.json +# shellcheck disable=SC2016 +perl -pi -e 's/^TODO: one sentence saying why this plugin exists.*$/$ENV{DESCRIPTION}/' README.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. @@ -71,7 +99,7 @@ for swept_json in .wp-env.json .wp-env.tests.json .wp-env.multisite.json compose done # A name or slug shorter than the template's leaves wrapped lines that Prettier joins, so the swept scripts are formatted -# again. Install scripts stay off, because the fill-in job holds a token that can push. +# again. Install scripts stay off, since formatting needs none of them. npm ci --ignore-scripts --no-audit --no-fund node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/EndToEnd}/**/*.{js,jsx,ts,tsx}' @@ -80,7 +108,11 @@ if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plu echo "::error::The fill-in left the template's names above." status=1 fi -if git grep -n -I -E '\b881[123]\b' -- . ':!.github/scripts/fill-in-scaffold.sh' ':!composer.lock' ':!package-lock.json'; then +if git grep -n -I -F -e "$TEMPLATE_DESCRIPTION" -- . ':!.github/scripts/fill-in-scaffold.sh'; then + echo "::error::The fill-in left the template's description above." + status=1 +fi +if git grep -n -I -w -E '881[123]' -- . ':!.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 diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index 2719beb..c1750d6 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -7,6 +7,10 @@ on: description: 'Display name, such as "Acme Orders"' required: true type: string + description: + description: 'One sentence saying why the plugin exists, such as "Acme Orders keeps every order a store takes in one place."' + required: true + type: string plugin-slug: description: 'Kebab-case slug, also the text domain and the entry file name, such as "acme-orders"' required: true @@ -34,14 +38,12 @@ jobs: if: ${{ !github.event.repository.is_template }} runs-on: ubuntu-latest timeout-minutes: 10 - permissions: - contents: write steps: - # 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 + persist-credentials: false + # setup-php hands its token to Composer on disk, where the npm packages the fill-in runs can read it, so this job's token is read-only. - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 with: php-version: '8.5' @@ -55,6 +57,7 @@ jobs: - name: Fill in the placeholders env: PLUGIN_NAME: ${{ inputs.plugin-name }} + DESCRIPTION: ${{ inputs.description }} SLUG: ${{ inputs.plugin-slug }} NS: ${{ inputs.php-namespace }} PREFIX: ${{ inputs.prefix }} @@ -62,12 +65,57 @@ jobs: REPO: ${{ github.repository }} run: .github/scripts/fill-in-scaffold.sh + # The entry runs before the floor gate and names the namespace, so one PHP 7.4 cannot parse stops the fill-in here, on the files Quality parses on PHP 7.4. + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '7.4' + coverage: none + tools: none + + - name: Check the PHP 7.4 syntax + env: + SLUG: ${{ inputs.plugin-slug }} + run: | + for file in "$SLUG.php" uninstall.php includes/*.php; do + php -l "$file" + done + + - name: Save the filled-in plugin as a patch + run: | + git add -A + git diff --cached --binary > "$RUNNER_TEMP/fill-in.patch" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: fill-in + path: ${{ runner.temp }}/fill-in.patch + if-no-files-found: error + retention-days: 1 + + push: + name: Commit and push + needs: fill-in + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: write # Pushes the filled-in plugin. + steps: + # Only git runs in this job, so the checkout keeps the credentials the push uses. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: true + + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: fill-in + path: ${{ runner.temp }} + - name: Commit and push env: SLUG: ${{ inputs.plugin-slug }} run: | + git apply --index "$RUNNER_TEMP/fill-in.patch" git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A git commit -m "Fill in scaffold: $SLUG" git push diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 53cb5c5..953c8f3 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -45,7 +45,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][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 @@ -83,9 +83,12 @@ jobs: - name: Fill in the scaffold env: PLUGIN_NAME: Acme Orders + # The quotes and the backslash check that the manifests' JSON strings escape the description. + DESCRIPTION: 'Acme Orders keeps a store''s "orders" in one place, C:\ included.' SLUG: acme-orders NS: AcmeOrders - PREFIX: dws_ao + # The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing. + PREFIX: dws_acmeextraordinaryorders PORT: '8821' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh @@ -101,3 +104,14 @@ jobs: npm run build npm run lint:scripts npm run lint:types + # Setting up PHP 7.4 replaces the PHP 8.5 the steps above need, so the fill-in job's last check runs last here too. + - uses: shivammathur/setup-php@eb7c497e18156a6bbabfef1d3a82760b9eda3962 # 2.40.0 + with: + php-version: '7.4' + coverage: none + tools: none + - name: Check the PHP 7.4 syntax + run: | + for file in acme-orders.php uninstall.php includes/*.php; do + php -l "$file" + done diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa1138b..e6285d6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: id: slug run: | slug=$(jq -r '.extra["text-domain"] // empty' composer.json) - if ! [[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then + if ! [[ "$slug" =~ ^[a-z][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 @@ -51,5 +51,5 @@ jobs: 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' - # A pull request or a dispatch on a branch rehearses the release without publishing it. - publish: ${{ github.ref_type == 'tag' }} + # A pull request, a dispatch on a branch or any tag on the template rehearses the release without publishing it. + publish: ${{ github.ref_type == 'tag' && !github.event.repository.is_template }} diff --git a/AGENTS.md b/AGENTS.md index 94e47f4..580dbff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 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`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, or guard them, which `phpstan.safeguards.neon` checks. 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. +9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. ## Style diff --git a/CHANGELOG.md b/CHANGELOG.md index cc205c3..d4faa6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this plugin are documented in this file. Format follows [ Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`. -## 2.0.0 - unreleased +## 1.0.0 - unreleased ### Added - Initial release. diff --git a/README.md b/README.md index bfeb07d..4dc454e 100644 --- a/README.md +++ b/README.md @@ -11,22 +11,22 @@ Create the plugin's repository from this template with GitHub's **Use this templ | Input | Example | What it sets | | --- | --- | --- | | `plugin-name` | `Acme Orders for WooCommerce` | the display name in the plugin header, the strings and the docs | +| `description` | `Acme Orders keeps every order a store takes in one place.` | the entry's `Description` header, `composer.json`'s and `package.json`'s `description` and the README's opening sentence; set the repository's GitHub description to the same sentence | | `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 | 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; +- renames the entry file and replaces the template's names, description, 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; - 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. +- deletes the disabled example include and any changelog fragments, and refreshes the Composer lock's content hash; +- fails when any name, description or port of the template is left, and otherwise pushes one commit to the branch it ran on. ## Naming - A WooCommerce plugin's display name reads `X for WooCommerce`, as WooCommerce's trademark guidelines ask, and its slug ends in `-for-woocommerce`. -- The prefix is `dws_` and one segment of up to 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 prefix is `dws_` and one segment of up to 23 lowercase letters and digits, such as `dws_lpmwc`, so no plugin's prefix starts with another's and the template's longest constants still pass PHPCS; the framework's admin notices refuse `dws_network` and `dws_hidden`; a WooCommerce plugin's segment ends in `wc`. - The namespace is flat: `DeepWebSolutions\\`, with no segment in between. - The repository is `wc-` for a WooCommerce plugin and `wp-` otherwise, as the plugin mirrors are named, and the fill-in names the Composer package `/` and the npm package `@/` after it. - Each plugin takes its own three consecutive wp-env ports, for its development, tests and multisite sites, apart from 8888 and 8889, which wp-env uses by default, and the template's own 8811, 8812 and 8813. @@ -60,7 +60,13 @@ Lint the scripts, styles and types: npm run lint ``` -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. +Update the dependencies within their constraints, and `@wordpress/*` to npm's latest rather than the WordPress floor's versions, since WordPress supplies those packages at runtime and PHPStan and the end-to-end suite on the floor's WordPress keep the plugin to its floor's APIs: + +```sh +npm run packages-update:all +``` + +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. The template itself publishes no release, even on a tag, and takes no changelog fragments, which the fill-in deletes. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. ## License diff --git a/README.scaffold.md b/README.scaffold.md index e8bd443..ffec189 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -61,7 +61,7 @@ The fill-in renamed the plugin and kept the template's demo, which boots and pas 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. +- The entry's build gate checks `blocks/build/blocks-manifest.php` and `assets/build/editor.asset.php`, and the plugin stays off until both exist, so it checks only files the remaining build writes, and drops its built-file check once nothing is built. - `lint:scripts` and `format:scripts` name 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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 843f421..3649c56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,12 +4,12 @@ This document describes how the plugin loads, boots, stores its data and leaves ## 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: +`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; `phpstan.safeguards.neon` analyzes it with the safeguards at PHP 7.4 and the oldest WordPress the bootstrap package runs on: 1. It defines `DWS_TPL_FILE` and registers `languages/`, which WordPress skips for a network-activated plugin. 2. It loads `includes/safeguards.php`, whose restrictions hold on any runtime that parses the plugin: below the floors, on a partial deploy and when the boot fails. -3. 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 past the safeguards loads. -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. +3. It stops with a notice when the prefixed framework is missing. Otherwise 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 past the safeguards loads. +4. It stops with the same notice when the built blocks or assets 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 `_`. diff --git a/docs/recipes.md b/docs/recipes.md index b2abb4d..8f54ecc 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -26,8 +26,8 @@ Replace `includes/safeguards.php` with the helper and the safeguard. The file ru /** * Holds the plugin's safeguards, which load before its floor gate, so they hold on a runtime below its floors and when the rest of the build fails to load. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -39,8 +39,8 @@ add_filter( 'map_meta_cap', 'dws_tpl_map_export_capability', 10, 2 ); /** * Returns whether a safeguard's filtered value relaxes it, which takes an explicit false once the plugin has finished booting. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $filtered The value the safeguard's filter returned for true. * @@ -53,8 +53,8 @@ function dws_tpl_is_relaxed( $filtered ): bool { /** * Maps the data export capability to do_not_allow, or to managing options while the plugin relaxes the export lock. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string[] $caps The primitive capabilities the capability maps to. * @param string $cap The capability being checked. @@ -69,8 +69,8 @@ function dws_tpl_map_export_capability( array $caps, string $cap ): array { /** * Filters whether the data export stays locked; an explicit false unlocks it once the plugin has booted. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param bool $restricted Whether the data export stays locked. */ @@ -161,8 +161,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param AdminNotices $notices The plugin's admin notices. */ @@ -175,8 +175,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin /** * Tells the current user, on their next admin screen, how many rows the export wrote. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param int $rows The number of rows the export wrote. * @@ -206,8 +206,8 @@ Add the option, its field and its default, a reader that guards the field and th /** * The option that holds the settings page's fields, keyed by field id. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -216,8 +216,8 @@ const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; /** * The settings field that holds the limit. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -226,8 +226,8 @@ 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 + * @since 1.0.0 + * @version 1.0.0 * * @var array */ @@ -238,8 +238,8 @@ const DWS_TPL_SETTING_DEFAULTS = array( /** * Returns a settings field as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -256,8 +256,8 @@ function dws_tpl_get_int_setting( string $field ): int { /** * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -286,8 +286,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\A /** * Adds the plugin's settings page under Settings. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInterface { // region INHERITED METHODS @@ -295,8 +295,8 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -306,8 +306,8 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_options_page( string $page_id ): array { @@ -363,8 +363,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerc /** * Adds the packing tab to the product editor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class ProductData implements ComponentInterface, ProductDataTabDefinitionInterface { // region INHERITED METHODS @@ -372,8 +372,8 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -383,8 +383,8 @@ final class ProductData implements ComponentInterface, ProductDataTabDefinitionI /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_product_data_tab( string $tab_id, \WC_Product $product ): array { @@ -436,8 +436,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\E /** * Adds an editorial note to the post editor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterface { // region MAGIC METHODS @@ -445,8 +445,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param AdminNotices $notices The plugin's admin notices, which report the form's rejections. */ @@ -461,8 +461,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -472,8 +472,8 @@ final class PostNote implements ComponentInterface, ObjectFormDefinitionInterfac /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_object_form( string $form_id, int $object_id ): array { @@ -515,8 +515,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerc /** * Adds the plugin's section to WooCommerce's Payments tab, and links to it from the plugin's row. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class PaymentSettings implements ComponentInterface, SectionFieldsDefinitionInterface { // region INHERITED METHODS @@ -524,8 +524,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -536,11 +536,18 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { + $methods = array(); + foreach ( WC()->payment_gateways()->payment_gateways() as $gateway ) { + if ( $gateway instanceof \WC_Payment_Gateway ) { + $methods[ $gateway->id ] = $gateway->get_method_title(); + } + } + return array( array( 'type' => 'title', @@ -553,6 +560,14 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'title' => __( 'Payment notes', 'dws-plugin-template' ), 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), ), + array( + 'type' => 'multiselect', + 'id' => DWS_TPL_OPTION_PAYMENT_METHODS, + 'title' => __( 'Payment methods', 'dws-plugin-template' ), + 'desc' => __( 'Add the note only to orders paid with these methods', 'dws-plugin-template' ), + 'class' => 'wc-enhanced-select', + 'options' => $methods, + ), array( 'type' => 'sectionend', 'id' => Plugin::PREFIX . '_payment_options', @@ -567,8 +582,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * Registers the plugin's section on the Payments tab. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param SettingsSectionRegistry $registry WooCommerce's registry of settings sections. * @@ -590,8 +605,8 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti /** * Adds the link to the plugin's section to its row on the Plugins screen. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string[] $links The row's action links. * @@ -607,7 +622,38 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti } ``` -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`. +WooCommerce stores each field in its own option under its id, flat rather than in a group: a checkbox stores `yes` or `no`, which `dws_tpl_get_bool_option()` reads, and a multiselect the list of the chosen keys, which a list reader reads. Add each key to `includes/options.php` with its default in `DWS_TPL_OPTION_DEFAULTS`, the multiselect's an empty array, whose `@var` then allows `list`, and the list reader beside the other typed readers: + +```php +/** + * The option that holds the payment methods whose orders get the payment note. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ +const DWS_TPL_OPTION_PAYMENT_METHODS = 'dws_tpl_payment_methods'; + +/** + * Returns an option as a list of strings, keeping the stored strings in order. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @param string $key The option's key. + * + * @return list + */ +function dws_tpl_get_list_option( string $key ): array { + $default = dws_tpl_get_option_default( $key ); + $stored = get_option( $key, $default ); + $stored = is_array( $stored ) ? $stored : $default; + return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); +} +``` + +WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays truthy, as gateway ids are. ## Recurring jobs @@ -619,8 +665,8 @@ Add the repair gate's option to `includes/options.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 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -645,8 +691,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\Psr\Log\LoggerInterface as PsrLoggerI /** * Prunes the plugin's expired records once a day. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Pruning implements ComponentInterface { // region FIELDS AND CONSTANTS @@ -654,8 +700,8 @@ final class Pruning implements ComponentInterface { /** * The hook the daily prune runs on. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -668,8 +714,8 @@ final class Pruning implements ComponentInterface { /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param SchedulerInterface $scheduler The plugin's scheduler. * @param PsrLoggerInterface $logger The plugin's logger. @@ -686,8 +732,8 @@ final class Pruning implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -703,8 +749,8 @@ final class Pruning implements ComponentInterface { /** * Ensures the daily prune unless a request did so within the last hour. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -721,8 +767,8 @@ final class Pruning implements ComponentInterface { /** * Schedules the daily prune unless it runs at that interval already, logging a scheduler that refuses. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -743,8 +789,8 @@ final class Pruning implements ComponentInterface { /** * Deletes the records that have expired. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -775,8 +821,8 @@ 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 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -907,15 +953,14 @@ A plugin that does not need WooCommerce drops the WooCommerce package, the WooCo 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: +2. In `phpstan.dist.neon`, delete the WooCommerce stubs from `scanFiles`: ```neon - scanFiles: - - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php + - 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. +3. In `dws-plugin-template.php`, delete the `Requires Plugins`, `WC requires at least` and `WC tested up to` headers, and in `tests/Unit/EntryHeadersTest.php` the row that ties `WC tested up to` to the locked WooCommerce. 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: @@ -962,8 +1007,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra /** * Registers the scheduler and the migrations, then builds the kernel and boots the plugin's components. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return bool */ @@ -1002,8 +1047,8 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Settings\A /** * Adds the plugin's settings page under Settings, which holds the limit and the consent to delete the plugin's data on uninstall. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Settings implements ComponentInterface, OptionsPageDefinitionInterface { // region INHERITED METHODS @@ -1011,8 +1056,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -1022,8 +1067,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_options_page( string $page_id ): array { @@ -1072,8 +1117,8 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa /** * Holds the plugin's settings option, its fields and their defaults, and reads each field as its type, guarding the stored value. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -1085,8 +1130,8 @@ defined( 'ABSPATH' ) || exit; /** * The option that holds the settings page's fields, keyed by field id. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1095,8 +1140,8 @@ const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; /** * The settings field that holds 'yes' where the site consents to uninstall removing the plugin's data. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1105,8 +1150,8 @@ const DWS_TPL_SETTING_DELETE_DATA = 'delete_data'; /** * The settings field that holds the limit. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -1115,8 +1160,8 @@ 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 + * @since 1.0.0 + * @version 1.0.0 * * @var array */ @@ -1128,8 +1173,8 @@ const DWS_TPL_SETTING_DEFAULTS = array( /** * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1144,8 +1189,8 @@ function dws_tpl_get_bool_setting( string $field ): bool { /** * Returns a settings field as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1160,8 +1205,8 @@ function dws_tpl_get_int_setting( string $field ): int { /** * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -1176,8 +1221,8 @@ function dws_tpl_validate_limit( $submitted ) { /** * Returns a settings field's stored value, or the fallback while the settings hold none. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @param mixed $fallback The value to return while nothing is stored. @@ -1193,8 +1238,8 @@ function dws_tpl_get_setting( string $field, $fallback ) { /** * Returns a settings field's default, or null for a field without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $field The field's id. * @@ -1211,8 +1256,8 @@ function dws_tpl_get_setting_default( string $field ) { /** * 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 + * @since 1.0.0 + * @version 1.0.0 * * @throws RuntimeException Thrown when the defaults could not be stored. * @@ -1258,7 +1303,7 @@ Uninstaller::run( - `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`. +- `PluginBootTest` expects nothing in `$plugin->kernel->skipped`, and drops the `DWS_TPL_DEBUG` row with its `get_threshold()` helper and the `WooCommerceLogger` and `RunInSeparateProcess` imports. - `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 diff --git a/dws-plugin-template.php b/dws-plugin-template.php index 8368fff..567d84e 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -3,7 +3,7 @@ * Plugin Name: DWS Plugin Template * Plugin URI: https://github.com/ahegyes/wordpress-plugin-template * Description: 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. - * Version: 2.0.0 + * Version: 1.0.0 * Requires at least: 7.1 * Tested up to: 7.1 * Requires PHP: 8.5 @@ -32,15 +32,18 @@ require_once __DIR__ . '/includes/safeguards.php'; // Before the floor gate, so the restrictions hold on any runtime that parses the plugin. $dws_tpl_bootstrap = __DIR__ . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; -if ( is_readable( $dws_tpl_bootstrap ) ) { - require_once $dws_tpl_bootstrap; - Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them. - if ( ! Requirements::check_floors( __FILE__ ) ) { - return; - } +if ( ! is_readable( $dws_tpl_bootstrap ) ) { // The floor gate lives in the bootstrap package, so nothing built may load without it. + add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); + return; } -if ( ! is_readable( $dws_tpl_bootstrap ) || ! is_readable( __DIR__ . '/blocks/build/blocks-manifest.php' ) ) { // The floor gate never ran, or the scripts were never built, so nothing built may load. +require_once $dws_tpl_bootstrap; +Updater::register( __FILE__ ); // Before the floor gate, so a release that lowers the floors still reaches a site below them. +if ( ! Requirements::check_floors( __FILE__ ) ) { + return; +} + +if ( ! is_readable( __DIR__ . '/blocks/build/blocks-manifest.php' ) || ! is_readable( __DIR__ . '/assets/build/editor.asset.php' ) ) { // The plugin reads both build trees unchecked, so a build that wrote only one of them must not load. add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); return; } @@ -54,10 +57,10 @@ } /** - * Reports a build that could not load, rethrowing in local and development environments and otherwise logging the error and showing the build notice. + * Reports a build that could not load, rethrowing in local and development environments and otherwise logging the error with its location and showing the build notice. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param Throwable $error The error that stopped the build. * @@ -70,15 +73,21 @@ function dws_tpl_handle_build_error( Throwable $error ): void { throw $error; } - error_log( 'DWS Plugin Template did not finish loading: ' . $error->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + // A partial deploy fails the same way on every request, so each distinct message is logged about once an hour, as best effort. + $key = 'dws_tpl_build_error_' . md5( $error->getMessage() ); + if ( false === get_transient( $key ) ) { + set_transient( $key, 1, HOUR_IN_SECONDS ); + error_log( 'DWS Plugin Template did not finish loading: ' . $error->getMessage() . ' in ' . $error->getFile() . ':' . $error->getLine() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- The plugin's logger is part of the build that failed. + } + add_action( 'all_admin_notices', 'dws_tpl_render_build_notice' ); } /** * Prints the notice that the plugin's build is incomplete to users who can activate plugins. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/functions.php b/functions.php index 029a5dd..94b0081 100644 --- a/functions.php +++ b/functions.php @@ -2,8 +2,8 @@ /** * Defines the plugin's accessor, boots it on plugins_loaded, unschedules its jobs on deactivation, declares its WooCommerce feature compatibility and loads the files in includes/. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -33,8 +33,8 @@ /** * Returns the plugin's composition root, built on first use. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return Plugin */ @@ -50,40 +50,46 @@ function dws_tpl_plugin(): Plugin { /** * Boots the plugin, reporting a composition root that cannot be built as an incomplete build. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ function dws_tpl_boot(): void { try { - dws_tpl_plugin()->boot(); + $plugin = dws_tpl_plugin(); } catch ( Throwable $error ) { dws_tpl_handle_build_error( $error ); + return; } + + $plugin->boot(); // PluginBoot reports a failed composition itself, and a hook listener's throw is not a build error. } /** * Unschedules the plugin's jobs on the site it is deactivated on, reporting a composition root that cannot be built as an incomplete build. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ function dws_tpl_deactivate(): void { try { - dws_tpl_plugin()->deactivate(); + $plugin = dws_tpl_plugin(); } catch ( Throwable $error ) { dws_tpl_handle_build_error( $error ); + return; } + + $plugin->deactivate(); } /** * Declares whether the plugin works with WooCommerce's order tables, which each plugin built from the template sets from its own evidence. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/includes/_disabled-example.php b/includes/_disabled-example.php index 1e732e3..9791cfa 100644 --- a/includes/_disabled-example.php +++ b/includes/_disabled-example.php @@ -2,8 +2,8 @@ /** * Stays unloaded, because functions.php skips the files in includes/ whose names start with an underscore. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ diff --git a/includes/assets.php b/includes/assets.php index 24bd58c..5dd0fb0 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -2,8 +2,8 @@ /** * Registers the plugin's built scripts and styles with the dependencies and version wp-scripts generates beside each. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the payload wp-scripts generates beside a built file, which lists its dependencies, script module imports included, and its version. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $path The built file's path, relative to the plugin's directory. * @@ -36,8 +36,8 @@ function dws_tpl_get_asset( string $path ): array { /** * Registers a built script with its generated dependencies and version, and loads its translations from languages/. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param non-empty-string $handle The script's handle. * @param non-empty-string $path The built script's path, relative to the plugin's directory. @@ -58,8 +58,8 @@ function dws_tpl_register_script( string $handle, string $path, array $args = ar /** * Registers a built stylesheet with its generated version, and its right-to-left variant for right-to-left languages. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param non-empty-string $handle The stylesheet's handle. * @param non-empty-string $path The built stylesheet's path, relative to the plugin's directory. diff --git a/includes/footprint.php b/includes/footprint.php index 8bf8a05..dac6f3c 100644 --- a/includes/footprint.php +++ b/includes/footprint.php @@ -2,8 +2,8 @@ /** * Declares the data the plugin stores, which uninstall removes where the site consents. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the plugin's stored data by kind, which covers every option, transient, user meta key and scheduled job under the plugin's prefix. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return array{option_prefixes: list, user_meta_site_prefixes: list, user_meta_global_prefixes: list, network_options: list, cron_hooks: list, as_groups: list} */ diff --git a/includes/metadata.php b/includes/metadata.php index 1ffc6ef..76ec661 100644 --- a/includes/metadata.php +++ b/includes/metadata.php @@ -4,8 +4,8 @@ * * A peer calls them from plugins_loaded on, since it may load before the plugin, and behind function_exists(), since a site below the floors or with a partial build may leave them undefined. Each reads only the plugin's header, so a getter that is defined still answers when a later include fails. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * Returns the plugin's slug, which is the text domain its header declares. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ @@ -27,8 +27,8 @@ function dws_tpl_get_plugin_slug(): string { /** * Returns the plugin's name from its header, translated only once init has fired, because WordPress flags translations loaded earlier. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ @@ -41,8 +41,8 @@ function dws_tpl_get_plugin_name(): string { /** * Returns the plugin's version from its header. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return string */ diff --git a/includes/migrations.php b/includes/migrations.php index e9b6fc6..99b88e7 100644 --- a/includes/migrations.php +++ b/includes/migrations.php @@ -2,8 +2,8 @@ /** * Holds the plugin's migration steps, which bring its stored data to the revision its code expects. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -13,8 +13,8 @@ /** * Returns the migration steps, append-only, where index 0 brings the data to revision 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return list */ @@ -27,8 +27,8 @@ function dws_tpl_get_migration_steps(): array { /** * Stores the defaults of the settings that need no translation, autoloaded, so a site that never saved them reads them without a query; a translated default is never stored, because it would keep the language the site had when the step ran. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @throws RuntimeException Thrown when a default could not be stored. * diff --git a/includes/options.php b/includes/options.php index 45a790e..0078f7b 100644 --- a/includes/options.php +++ b/includes/options.php @@ -2,8 +2,8 @@ /** * Holds the plugin's option keys and defaults, and reads each option as its type, guarding the stored value. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ @@ -15,8 +15,8 @@ /** * The option that holds 'yes' where the site consents to uninstall removing the plugin's data. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -25,8 +25,8 @@ /** * The option that holds 'yes' where the site greets returning customers on their account dashboard. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -35,8 +35,8 @@ /** * The option that holds the greeting. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -45,8 +45,8 @@ /** * The option that holds the number of orders a customer places before the greeting shows. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -55,10 +55,10 @@ /** * The value each option reads as while nothing valid is stored. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * - * @var array> + * @var array */ const DWS_TPL_OPTION_DEFAULTS = array( DWS_TPL_OPTION_DELETE_DATA => false, @@ -69,8 +69,8 @@ /** * Returns an option as a boolean, which reads checkbox values such as 'yes' and 'no'. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -85,8 +85,8 @@ function dws_tpl_get_bool_option( string $key ): bool { /** * Returns an option as an integer. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -101,8 +101,8 @@ function dws_tpl_get_int_option( string $key ): int { /** * Returns an option as a string, reading a missing or empty value of an option with a translated default as that default, only after init. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -119,29 +119,11 @@ function dws_tpl_get_string_option( string $key ): string { return is_string( $stored ) ? $stored : ( is_string( $default ) ? $default : '' ); } -/** - * Returns an option as a list of strings, keeping the stored strings in order. - * - * @since 2.0.0 - * @version 2.0.0 - * - * @param string $key The option's key. - * - * @return list - */ -function dws_tpl_get_list_option( string $key ): array { - $default = dws_tpl_get_option_default( $key ); - $stored = get_option( $key, $default ); - $stored = is_array( $stored ) ? $stored : $default; - - return is_array( $stored ) ? array_values( array_filter( $stored, 'is_string' ) ) : array(); -} - /** * Returns an option's translated default, which exists only after init, or an empty string for an option without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * @@ -159,8 +141,8 @@ function dws_tpl_get_option_text( string $key ): string { /** * Returns the number of orders before the greeting shows in the form WooCommerce stores, or an error when the submission is not a whole number of at least 1. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param mixed $submitted The submitted value. * @@ -175,8 +157,8 @@ function dws_tpl_validate_greeting_orders( $submitted ) { /** * Returns the option's default, or null for an option without one. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param string $key The option's key. * diff --git a/includes/safeguards.php b/includes/safeguards.php index fe2b6e3..b6bbfc7 100644 --- a/includes/safeguards.php +++ b/includes/safeguards.php @@ -2,8 +2,8 @@ /** * Holds the plugin's safeguards, which load before its floor gate, so they hold on a runtime below its floors and when the rest of the build fails to load. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */ diff --git a/package.json b/package.json index c6ab20b..1879b51 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@ahegyes/wordpress-plugin-template", "private": true, - "description": "DWS WordPress plugin template — wp-env, script and style tooling.", + "description": "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.", "license": "GPL-3.0-or-later", "repository": { "type": "git", diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 41e5b67..2f49558 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,4 +1,4 @@ -# The WordPress extensions are listed here because composer.json keeps them from installing themselves, and before configs' profile because wp-compat's neon resets WPCompat.requiresAtLeast to null. +# The WordPress extensions are listed here because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,19 +7,23 @@ includes: parameters: paths: - - dws-plugin-template.php - functions.php - uninstall.php - includes - models - src - # phpstan.safeguards.neon analyzes the safeguards at the oldest runtime they parse on. + # phpstan.safeguards.neon analyzes the entry and the safeguards at the oldest runtime they parse on, so this profile only scans them. excludePaths: - includes/safeguards.php scanFiles: + - dws-plugin-template.php - includes/safeguards.php - vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php # The scoped packages load through vendor-prefixed/scoper-autoload.php, which PHPStan does not read. scanDirectories: - vendor-prefixed + # wp-compat reads the floor from the entry's Requires at least only while requiresAtLeast, which configs' profile sets, is null. + WPCompat: + pluginFile: dws-plugin-template.php + requiresAtLeast: null diff --git a/phpstan.safeguards.neon b/phpstan.safeguards.neon index e2f6abc..8e38407 100644 --- a/phpstan.safeguards.neon +++ b/phpstan.safeguards.neon @@ -1,4 +1,4 @@ -# The safeguards load before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. +# The entry and the safeguards run before the floor gate, so they are analyzed at the oldest PHP and WordPress the bootstrap package runs on, which the framework's phpstan.bootstrap.neon sets; the WordPress extensions are listed because composer.json keeps them from installing themselves. includes: - vendor/szepeviktor/phpstan-wordpress/extension.neon - vendor/johnbillion/wp-compat/extension.neon @@ -7,7 +7,17 @@ includes: parameters: paths: + - dws-plugin-template.php - includes/safeguards.php phpVersion: 70400 WPCompat: requiresAtLeast: '5.2' + # The entry loads the scoped bootstrap package by path, before any autoloader. + scanDirectories: + - vendor-prefixed/ahegyes/wp-framework-bootstrap + ignoreErrors: + # The entry's build-error handler runs only once the floor gate has passed. + - + identifier: WPCompat.functionNotAvailable + message: '#^wp_get_environment_type\(\) #' + path: dws-plugin-template.php diff --git a/src/Blocks.php b/src/Blocks.php index d03e8e8..5bdafd2 100644 --- a/src/Blocks.php +++ b/src/Blocks.php @@ -9,8 +9,8 @@ /** * Registers the plugin's blocks from their build manifest, and the block-editor script that adds their styles. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Blocks implements ComponentInterface { // region INHERITED METHODS @@ -18,8 +18,8 @@ final class Blocks implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -34,8 +34,8 @@ public function register_hooks(): void { /** * Registers every built block from the manifest, which reads all their metadata from one file. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -46,8 +46,8 @@ public function register_blocks(): void { /** * Enqueues the block-editor script. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/src/Feature/Feature.php b/src/Feature/Feature.php index 31e3903..37fc224 100644 --- a/src/Feature/Feature.php +++ b/src/Feature/Feature.php @@ -10,8 +10,8 @@ /** * Groups the greeting's components, which load only on sites that switch the greeting on. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Feature implements CompositeComponentInterface, ConditionalComponentInterface { // region INHERITED METHODS @@ -19,8 +19,8 @@ final class Feature implements CompositeComponentInterface, ConditionalComponent /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public static function should_load(): bool { @@ -30,8 +30,8 @@ public static function should_load(): bool { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public static function get_child_component_classes(): array { @@ -41,8 +41,8 @@ public static function get_child_component_classes(): array { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void {} diff --git a/src/Feature/FeatureDetail.php b/src/Feature/FeatureDetail.php index 58b63bf..be3f1d4 100644 --- a/src/Feature/FeatureDetail.php +++ b/src/Feature/FeatureDetail.php @@ -10,8 +10,8 @@ /** * Greets customers on their account dashboard once they have placed the set number of orders. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class FeatureDetail implements ComponentInterface { // region INHERITED METHODS @@ -19,8 +19,8 @@ final class FeatureDetail implements ComponentInterface { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -35,8 +35,8 @@ public function register_hooks(): void { /** * Prints the greeting as a WooCommerce notice to a customer whose orders reach the set number. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -52,8 +52,8 @@ public function print_greeting(): void { /** * Enqueues the greeting's stylesheet on the account dashboard. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ diff --git a/src/Plugin.php b/src/Plugin.php index d942889..d82d9c6 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -18,8 +18,8 @@ /** * Composes the plugin from its logger, scheduler, migrations and components, inside the framework's boot boundary. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Plugin { // region FIELDS AND CONSTANTS @@ -27,8 +27,8 @@ final class Plugin { /** * The plugin's slug, which is also its text domain and its log source. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -37,8 +37,8 @@ final class Plugin { /** * The prefix of the plugin's hook names, options, transients and scheduled jobs. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var string */ @@ -47,8 +47,8 @@ final class Plugin { /** * The top-level components, in hook-registration order. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var list */ @@ -63,8 +63,8 @@ final class Plugin { /** * The boot boundary, whose status tells whether the plugin booted. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PluginBoot */ @@ -73,8 +73,8 @@ final class Plugin { /** * The scheduler, which registers its WP-Cron schedules even while the plugin is blocked. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var Scheduler */ @@ -83,8 +83,8 @@ final class Plugin { /** * The migration runner, once the composition built it. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var MigrationRunner|null */ @@ -93,8 +93,8 @@ final class Plugin { /** * The kernel, once the composition built it, which tells which components loaded and which were gated off. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PluginKernel|null */ @@ -103,8 +103,8 @@ final class Plugin { /** * The logger every part of the plugin writes to. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @var PsrLoggerInterface */ @@ -117,8 +117,8 @@ final class Plugin { /** * Constructor. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ public function __construct() { $this->logger = new WooCommerceLogger( self::SLUG, \defined( 'DWS_TPL_DEBUG' ) && true === \constant( 'DWS_TPL_DEBUG' ) ? 'debug' : null ); @@ -133,8 +133,8 @@ public function __construct() { /** * Runs the composition inside the framework's boot boundary. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -145,8 +145,8 @@ public function boot(): void { /** * Unschedules the footprint's jobs on the current site, logging each one that could not be unscheduled. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return void */ @@ -175,8 +175,8 @@ public function deactivate(): void { /** * Builds the plugin and boots its components, returning false when WooCommerce is missing or too old. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @return bool */ @@ -198,8 +198,8 @@ protected function compose(): bool { /** * Returns the container's definitions of the services the root builds, which the kernel's components share. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param PsrLoggerInterface $logger The plugin's logger. * @param Scheduler $scheduler The plugin's scheduler. diff --git a/src/Settings.php b/src/Settings.php index 533a523..60ba651 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -11,8 +11,8 @@ /** * Adds the plugin's WooCommerce settings tab, which holds the greeting's settings and the consent to delete the plugin's data on uninstall. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ final class Settings implements ComponentInterface, SectionFieldsDefinitionInterface { // region INHERITED METHODS @@ -20,8 +20,8 @@ final class Settings implements ComponentInterface, SectionFieldsDefinitionInter /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function register_hooks(): void { @@ -31,8 +31,8 @@ public function register_hooks(): void { /** * {@inheritDoc} * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 */ #[\Override] public function get_section_fields( string $tab_id, string $section_id ): array { @@ -99,8 +99,8 @@ public function get_section_fields( string $tab_id, string $section_id ): array /** * Adds the plugin's settings tab, which WooCommerce's settings page class must exist to construct. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @param array $pages The settings tabs. * diff --git a/tests/Integration/Feature/FeatureDetailTest.php b/tests/Integration/Feature/FeatureDetailTest.php index 696158e..00934fd 100644 --- a/tests/Integration/Feature/FeatureDetailTest.php +++ b/tests/Integration/Feature/FeatureDetailTest.php @@ -81,6 +81,21 @@ public function test_the_greeting_style_loads_on_the_account_dashboard_only(): v wp_deregister_style( 'dws-plugin-template-greeting' ); } + public function test_the_greeting_style_skips_the_account_page_endpoints(): void { + global $wp; + + add_filter( 'woocommerce_is_account_page', '__return_true' ); + $wp->query_vars['orders'] = ''; + try { + new FeatureDetail()->enqueue_style(); + } finally { + unset( $wp->query_vars['orders'] ); + remove_filter( 'woocommerce_is_account_page', '__return_true' ); + } + + self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) ); + } + public function test_a_customer_whose_orders_reach_the_set_number_is_greeted_as_text(): void { update_option( DWS_TPL_OPTION_GREETING, 'Hi & welcome' ); update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 920d224..438a245 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -13,7 +13,7 @@ final class OptionsTest extends TestCase { protected const string KEY = Plugin::PREFIX . '_options_test'; - protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING ); + protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); protected array $stored = array(); @@ -50,15 +50,6 @@ public static function stored_strings(): array { ); } - public static function stored_lists(): array { - return array( - 'list' => array( array( 'a', 'b' ), array( 'a', 'b' ) ), - 'mixed' => array( array( 'a', 7, 'b' ), array( 'a', 'b' ) ), - 'keyed' => array( array( 'x' => 'a' ), array( 'a' ) ), - 'string' => array( 'a', array() ), - ); - } - public static function submitted_order_counts(): array { return array( 'one' => array( '1', '1' ), @@ -107,18 +98,17 @@ public function test_an_int_reads_a_whole_number_or_zero( mixed $stored, int $ex self::assertSame( $expected, dws_tpl_get_int_option( self::KEY ) ); } - #[DataProvider( 'stored_strings' )] - public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $stored, string $expected ): void { - update_option( self::KEY, $stored ); + public function test_an_int_that_is_not_a_whole_number_reads_its_default(): void { + update_option( DWS_TPL_OPTION_GREETING_ORDERS, 'seven' ); - self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); + self::assertSame( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_GREETING_ORDERS ], dws_tpl_get_int_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); } - #[DataProvider( 'stored_lists' )] - public function test_a_list_reads_the_stored_strings_in_order( mixed $stored, array $expected ): void { + #[DataProvider( 'stored_strings' )] + public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $stored, string $expected ): void { update_option( self::KEY, $stored ); - self::assertSame( $expected, dws_tpl_get_list_option( self::KEY ) ); + self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); } public function test_a_string_without_a_stored_value_reads_its_translated_default(): void { @@ -136,6 +126,12 @@ public function test_a_string_without_a_stored_value_reads_its_translated_defaul self::assertSame( 'Hello', dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); } + public function test_a_string_that_is_not_a_string_reads_its_translated_default(): void { + update_option( DWS_TPL_OPTION_GREETING, array( 'Hello' ) ); + + self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + } + public function test_an_option_without_a_translated_default_has_no_text(): void { self::assertSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_FEATURE ) ); } diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 5e04219..7659cfd 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Uninstall\Uninstaller; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; @@ -25,6 +26,32 @@ public function test_a_missing_blocks_manifest_shows_the_build_notice_and_loads_ self::assertSame( '[false,true]', $request['last'] ); } + public function test_a_missing_assets_build_shows_the_build_notice_and_loads_nothing_built(): void { + $asset = \dirname( DWS_TPL_FILE ) . '/assets/build/editor.asset.php'; + \rename( $asset, "$asset.hidden" ); + try { + $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); + } finally { + \rename( "$asset.hidden", $asset ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,true]', $request['last'] ); + } + + public function test_a_missing_bootstrap_package_shows_the_build_notice_and_loads_nothing_built(): void { + $bootstrap = \dirname( DWS_TPL_FILE ) . '/vendor-prefixed/ahegyes/wp-framework-bootstrap/load.php'; + \rename( $bootstrap, "$bootstrap.hidden" ); + try { + $request = self::request( 'echo wp_json_encode( array( function_exists( "dws_tpl_boot" ), false !== has_action( "all_admin_notices", "dws_tpl_render_build_notice" ) ) );' ); + } finally { + \rename( "$bootstrap.hidden", $bootstrap ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( '[false,true]', $request['last'] ); + } + public function test_an_include_that_throws_shows_the_build_notice_and_boots_nothing(): void { $include = \dirname( DWS_TPL_FILE ) . '/includes/zz-throws.php'; \file_put_contents( $include, "getValue( new Plugin() ); + + self::assertInstanceOf( WooCommerceLogger::class, $logger ); + self::assertSame( self::get_threshold( new WooCommerceLogger( Plugin::SLUG, 'debug' ) ), self::get_threshold( $logger ) ); + } + public function test_the_stored_data_is_at_the_revision_of_the_code(): void { self::assertInstanceOf( MigrationRunner::class, dws_tpl_plugin()->migrations ); self::assertSame( \count( dws_tpl_get_migration_steps() ), MigrationRunner::get_revision( Plugin::PREFIX . '_revision' ) ); } + + protected static function get_threshold( WooCommerceLogger $logger ): mixed { + return new \ReflectionProperty( WooCommerceLogger::class, 'threshold' )->getValue( $logger ); + } } diff --git a/tests/README.md b/tests/README.md index 67c13d4..3a5db9e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -6,7 +6,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | -| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage | +| Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree, the footprint's shape and coverage, and the entry's `WC tested up to` against the locked WooCommerce | | Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded, and the multisite site for its `multisite` group | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, the peer getters, a partial deploy: a missing build file or engine, or an include that throws, and the boot and settings on a second site of a network | | End to end | `tests/EndToEnd/` | the tests site, in a browser through Playwright | the settings tab by keyboard, a rejected and a corrected value, accessibility through axe, and the plugin with WooCommerce deactivated | @@ -57,7 +57,7 @@ composer quality-check - **Tests** runs Unit once, and Integration, its `multisite` group 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, its `multisite` group 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. +- **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; it publishes only on a tag, and never on the template. - **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 diff --git a/tests/Unit/EntryHeadersTest.php b/tests/Unit/EntryHeadersTest.php new file mode 100644 index 0000000..c1d41f0 --- /dev/null +++ b/tests/Unit/EntryHeadersTest.php @@ -0,0 +1,20 @@ + 'wp-plugin/woocommerce' === $package['name'] ); + $entry = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/' . Plugin::SLUG . '.php' ); + + self::assertIsArray( $woocommerce ); + self::assertSame( 1, \preg_match( '/^\s*\*\s*WC tested up to:\s*(\S+)\s*$/m', $entry, $header ) ); + self::assertSame( \implode( '.', \array_slice( \explode( '.', $woocommerce['version'] ), 0, 2 ) ), $header[1] ); + } +} diff --git a/uninstall.php b/uninstall.php index 22e437d..a470ee4 100644 --- a/uninstall.php +++ b/uninstall.php @@ -2,8 +2,8 @@ /** * Removes the plugin's data where the site consents, through the framework's uninstall engine, once the site meets the plugin's floors. * - * @since 2.0.0 - * @version 2.0.0 + * @since 1.0.0 + * @version 1.0.0 * * @package DeepWebSolutions\PluginTemplate */