diff --git a/.distignore b/.distignore index ee1bef4..20344fb 100644 --- a/.distignore +++ b/.distignore @@ -15,6 +15,7 @@ CHANGELOG.md CLAUDE.md CONTRIBUTING.md README.md +README.scaffold.md SECURITY.md changelog/ diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index b88e3ee..f77ccff 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -7,7 +7,7 @@ body: attributes: label: Affected area options: - - Plugin runtime (Plugin singleton, WelcomeNotice, framework wiring) + - Plugin runtime (composition root, components, framework wiring) - Composer scoping pipeline (scoper.inc.php, post-autoload-dump) - Changelog tooling (composer changelog:* scripts) - CI workflows (quality.yml, tests.yml) diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 9a5659a..7f6f04b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,7 +7,7 @@ body: attributes: label: Target area options: - - Plugin runtime scaffold (Plugin, WelcomeNotice, framework wiring) + - Plugin runtime scaffold (composition root, components, framework wiring) - Composer scoping pipeline - Changelog tooling - CI workflows diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 2fa4d33..2301e76 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -14,14 +14,16 @@ grep -Eq '^[A-Za-z][A-Za-z0-9]*$' <<< "$NS" || { echo "::error::php-namespace mu # 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 +# 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 export SCREAMING_PREFIX="${PREFIX^^}" export TESTS_PORT=$(( PORT + 1 )) export REPO_NAME="${REPO#*/}" git mv --force README.scaffold.md README.md +# The scaffold README becomes the plugin's README.md, which .distignore already excludes. +perl -ni -e 'print unless /^README\.scaffold\.md$/' .distignore git rm --quiet includes/_disabled-example.php # The template's recipe test loads the recipe's safeguard helper, which a plugin that applies the recipe declares # itself, so only the recipe's own test stays. @@ -65,6 +67,11 @@ for swept_json in .wp-env.json .wp-env.tests.json composer.json package.json pac php -r 'json_decode( (string) file_get_contents( $argv[1] ), false, 512, JSON_THROW_ON_ERROR );' "$swept_json" 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. +npm ci --ignore-scripts --no-audit --no-fund +node_modules/.bin/wp-scripts format '{blocks/src,assets/src,tests/e2e}/**/*.{js,jsx,ts,tsx}' + status=0 if git grep -n -I -E 'dws-plugin-template|dws_tpl|DWS_TPL|PluginTemplate|DWS Plugin Template|wordpress-plugin-template' -- . ':!.github/scripts/fill-in-scaffold.sh'; then echo "::error::The fill-in left the template's names above." diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 212883a..3559bf3 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-supply-chain-audit.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: npm-audit-flags: '--omit=dev --audit-level=high' diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 18ef77a..cc091ec 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-codeql.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index af57e74..6d02d25 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -48,6 +48,10 @@ jobs: coverage: none tools: composer:v2 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + - name: Fill in the placeholders env: PLUGIN_NAME: ${{ inputs.plugin-name }} diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index d0e126d..4b91e5e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -11,20 +11,20 @@ permissions: jobs: lint-php: name: PHP Lint - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-lint.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-scripts-styles-lint.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-block-json-check.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b slug: name: Plugin slug @@ -50,7 +50,7 @@ jobs: php-syntax: name: PHP syntax needs: slug - uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-php-syntax-check.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: php-versions: '["7.4"]' paths: ${{ format('["{0}.php", "uninstall.php", "includes"]', needs.slug.outputs.slug) }} @@ -85,6 +85,10 @@ jobs: php-version: '8.5' coverage: none tools: composer:v2 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: package.json + cache: 'npm' - name: Fill in the scaffold env: PLUGIN_NAME: Acme Orders @@ -100,3 +104,9 @@ jobs: run: | composer lint:php composer test:unit + # The display name lands in the block's JSX, which only a build and the script and type lints parse. + - name: Build and lint the filled-in plugin's scripts + run: | + npm run build + npm run lint:scripts + npm run lint:types diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26162f5..9a8ab70 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: permissions: actions: read contents: write - uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-release.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: project-path: '.' plugin-slug: ${{ needs.slug.outputs.slug }} diff --git a/.github/workflows/tests-nightly.yml b/.github/workflows/tests-nightly.yml index 211133d..b5613b4 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: artifact-slug: nightly php-version: '8.5' diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 752b63f..d780ecf 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: php-version: '8.5' composer-script: 'test:unit' @@ -52,7 +52,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-phpunit.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b with: php-version: '8.5' wp-version: ${{ matrix.wp }} @@ -66,7 +66,7 @@ jobs: fail-fast: false matrix: wp: ${{ fromJSON(needs.wordpress.outputs.versions) }} - uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-playwright-e2e.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b 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 dc6460d..880dc25 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@9f64dc77b25727130884eaf427d2bd75493557ce + uses: ahegyes/wordpress-configs/.github/workflows/reusable-workflow-checks.yml@3cbd985255e76f8026644ccdee5be0b2b2da312b diff --git a/AGENTS.md b/AGENTS.md index 559a291..3062dbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Both run PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI run 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. +5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. 7. **Safeguards** live in `includes/safeguards.php`, call only WordPress and that file, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7a1abfb..b2c0bc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,18 +2,20 @@ ## Changelog entries -One fragment per user-visible change so PRs don't conflict on `CHANGELOG.md`: +Each user-visible change adds one fragment, so pull requests never conflict on `CHANGELOG.md`: ```bash composer changelog:add ``` -Interactive prompt (Significance + Type + entry); fragment lands in `changelog/`, commit it with the PR. CI validates via `composer changelog:validate`. +The command asks for the change's significance, its type and its entry, and writes the fragment to `changelog/`, which the pull request commits. CI validates the fragments with `composer changelog:validate`. ## Releases +A release writes the fragments into a new version block of `CHANGELOG.md`, whose version the fragments' significance sets: + ```bash composer changelog:write ``` -Aggregates fragments → new `CHANGELOG.md` version block (semver computed from fragment significance). Commit + tag. +The release commit also sets the entry file's `Version:` header to that version, and `readme.txt`'s `Stable tag:` where the plugin has one, because the release refuses a tag either differs from. Pushing the tag `v` on that commit starts the release. diff --git a/README.md b/README.md index 9b55319..b5796d7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. -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. +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, an empty safeguards slot 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. ## Starting a plugin diff --git a/README.scaffold.md b/README.scaffold.md index d21ded2..77d16aa 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -1,6 +1,6 @@ # 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. +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 and `package.json`'s `description`, the entry file's `Description` header and the repository's GitHub description. ## Requirements diff --git a/SECURITY.md b/SECURITY.md index 7708d50..b28bea2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -11,4 +11,4 @@ In scope: the plugin's PHP, JavaScript and CSS, its prefixing configuration (`sc 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. +- 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`. The Supply-Chain Audit workflow fails on an advisory anywhere in the Composer lock, and on a high-severity one among the npm runtime dependencies, of which the template has none; advisories in the npm development tools reach Dependabot alerts instead. diff --git a/composer.lock b/composer.lock index f148f46..2d62b34 100644 --- a/composer.lock +++ b/composer.lock @@ -13,12 +13,12 @@ "source": { "type": "git", "url": "https://github.com/ahegyes/wordpress-configs.git", - "reference": "9f64dc77b25727130884eaf427d2bd75493557ce" + "reference": "3cbd985255e76f8026644ccdee5be0b2b2da312b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/9f64dc77b25727130884eaf427d2bd75493557ce", - "reference": "9f64dc77b25727130884eaf427d2bd75493557ce", + "url": "https://api.github.com/repos/ahegyes/wordpress-configs/zipball/3cbd985255e76f8026644ccdee5be0b2b2da312b", + "reference": "3cbd985255e76f8026644ccdee5be0b2b2da312b", "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-28T20:26:01+00:00" + "time": "2026-10-29T14:52:37+00:00" }, { "name": "ahegyes/wp-framework-bootstrap", diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 118be5a..ff706c1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -34,7 +34,7 @@ The request that activates the plugin does not boot it, because WordPress includ ## 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`. +- **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`; a field shows one as its placeholder and stores an empty value, which its reader reads as the default, so no save stores the admin's language. - **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. diff --git a/docs/recipes.md b/docs/recipes.md index 50ae9bc..75a4013 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -326,6 +326,7 @@ final class SettingsPage implements ComponentInterface, OptionsPageDefinitionInt 'type' => 'number', 'id' => DWS_TPL_SETTING_LIMIT, 'title' => __( 'Limit', 'dws-plugin-template' ), + 'default' => (string) DWS_TPL_SETTING_DEFAULTS[ DWS_TPL_SETTING_LIMIT ], 'custom_attributes' => array( 'min' => '1', 'step' => '1', @@ -708,7 +709,7 @@ final class Pruning implements ComponentInterface { * @return void */ public function repair_schedule(): void { - $ensured_at = filter_var( get_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT, 0 ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? 0; + $ensured_at = dws_tpl_get_int_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT ); if ( time() - $ensured_at < HOUR_IN_SECONDS ) { return; } @@ -1041,6 +1042,7 @@ final class Settings implements ComponentInterface, OptionsPageDefinitionInterfa 'type' => 'number', 'id' => DWS_TPL_SETTING_LIMIT, 'title' => __( 'Limit', 'dws-plugin-template' ), + 'default' => (string) DWS_TPL_SETTING_DEFAULTS[ DWS_TPL_SETTING_LIMIT ], 'custom_attributes' => array( 'min' => '1', 'step' => '1', diff --git a/dws-plugin-template.php b/dws-plugin-template.php index 1038e8a..8e0a8cd 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -2,7 +2,7 @@ /** * Plugin Name: DWS Plugin Template * Plugin URI: https://github.com/ahegyes/wordpress-plugin-template - * Description: Starting point for WordPress plugins built on the DWS framework. + * 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 * Requires at least: 7.1 * Requires PHP: 8.5 diff --git a/functions.php b/functions.php index 02a16f6..029a5dd 100644 --- a/functions.php +++ b/functions.php @@ -13,10 +13,6 @@ defined( 'ABSPATH' ) || exit; -add_action( 'plugins_loaded', 'dws_tpl_boot', 15 ); -add_action( 'before_woocommerce_init', 'dws_tpl_declare_compatibility' ); -register_deactivation_hook( DWS_TPL_FILE, 'dws_tpl_deactivate' ); - $dws_tpl_includes = glob( __DIR__ . '/includes/*.php' ); if ( false === $dws_tpl_includes ) { throw new RuntimeException( 'The includes directory could not be read.' ); @@ -29,6 +25,11 @@ } } +// The hooks come last, so an include that throws leaves nothing of the plugin hooked. +add_action( 'plugins_loaded', 'dws_tpl_boot', 15 ); +add_action( 'before_woocommerce_init', 'dws_tpl_declare_compatibility' ); +register_deactivation_hook( DWS_TPL_FILE, 'dws_tpl_deactivate' ); + /** * Returns the plugin's composition root, built on first use. * @@ -63,7 +64,7 @@ function dws_tpl_boot(): void { } /** - * Unschedules the plugin's jobs on the site it is deactivated on. + * 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 @@ -71,7 +72,11 @@ function dws_tpl_boot(): void { * @return void */ function dws_tpl_deactivate(): void { - dws_tpl_plugin()->deactivate(); + try { + dws_tpl_plugin()->deactivate(); + } catch ( Throwable $error ) { + dws_tpl_handle_build_error( $error ); + } } /** diff --git a/includes/options.php b/includes/options.php index 0042b25..45a790e 100644 --- a/includes/options.php +++ b/includes/options.php @@ -99,7 +99,7 @@ function dws_tpl_get_int_option( string $key ): int { } /** - * Returns an option as a string, which reads an option with a translated default only after init. + * 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 @@ -109,8 +109,12 @@ function dws_tpl_get_int_option( string $key ): int { * @return string */ function dws_tpl_get_string_option( string $key ): string { - $default = dws_tpl_get_option_default( $key ) ?? dws_tpl_get_option_text( $key ); + $text = dws_tpl_get_option_text( $key ); + $default = dws_tpl_get_option_default( $key ) ?? $text; $stored = get_option( $key, $default ); + if ( '' === $stored ) { + return $text; // The field shows a translated default as its placeholder, so an untouched field stores an empty value. + } return is_string( $stored ) ? $stored : ( is_string( $default ) ? $default : '' ); } diff --git a/package-lock.json b/package-lock.json index 84d0c10..f9ec75a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,18 @@ "name": "@ahegyes/dws-plugin-template", "license": "GPL-2.0-or-later", "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#3cbd985255e76f8026644ccdee5be0b2b2da312b", "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.64.1", + "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@wordpress/block-editor": "^19.0.0", "@wordpress/blocks": "^17.0.0", "@wordpress/e2e-test-utils-playwright": "^3.1.0", "@wordpress/env": "^12.0.0", "@wordpress/i18n": "^7.0.0", - "@wordpress/scripts": "^37.0.0" + "@wordpress/scripts": "^37.0.0", + "typescript": "^6.0.3" }, "engines": { "node": ">=26", @@ -25,8 +27,8 @@ }, "node_modules/@ahegyes/wordpress-configs": { "version": "0.0.0", - "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", - "integrity": "sha512-Yk/hpkF7NQoZHAgDgByPOV5zENW9oaeb58MdX9fcOKlnlJs0C+lwI7DLBrVVmzNzBNrLkBCO6hVPoYH8/xeFhw==", + "resolved": "git+ssh://git@github.com/ahegyes/wordpress-configs.git#3cbd985255e76f8026644ccdee5be0b2b2da312b", + "integrity": "sha512-zTqkUCRwgWiUbcr6mm6QcOgObxrz+m1lYuybslhMRm5DHg2ZQC0zwi/cxNZAB/+AZduTfTbzHM+nu1Dpw523/Q==", "dev": true, "license": "MIT", "dependencies": { @@ -41,7 +43,8 @@ "@wordpress/eslint-plugin": ">=28.0.0", "@wordpress/postcss-plugins-preset": ">=6.0.0", "@wordpress/scripts": ">=37.0.0", - "@wordpress/stylelint-config": ">=27.0.0" + "@wordpress/stylelint-config": ">=27.0.0", + "typescript": ">=6.0.0" } }, "node_modules/@ariakit/components": { @@ -23811,7 +23814,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 4095178..611025c 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,18 @@ "npm": ">=11" }, "devDependencies": { - "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#9f64dc77b25727130884eaf427d2bd75493557ce", + "@ahegyes/wordpress-configs": "git+https://github.com/ahegyes/wordpress-configs.git#3cbd985255e76f8026644ccdee5be0b2b2da312b", "@axe-core/playwright": "^4.14.0", "@playwright/test": "^1.64.1", + "@types/node": "^26.6.5", "@types/react": "^19.3.0", "@wordpress/block-editor": "^19.0.0", "@wordpress/blocks": "^17.0.0", "@wordpress/e2e-test-utils-playwright": "^3.1.0", "@wordpress/env": "^12.0.0", "@wordpress/i18n": "^7.0.0", - "@wordpress/scripts": "^37.0.0" + "@wordpress/scripts": "^37.0.0", + "typescript": "^6.0.3" }, "scripts": { "setup": "npm run packages-install:all && npm run build && npm run wp-env:start", @@ -46,11 +48,9 @@ "start": "npm run start:blocks & npm run start:assets & wait", "start:blocks": "wp-scripts start --webpack-src-dir=blocks/src --output-path=blocks/build --blocks-manifest", "start:assets": "wp-scripts start editor=./assets/src/editor.ts greeting=./assets/src/greeting.scss --output-path=assets/build", - "lint:scripts": "wp-scripts lint-js blocks/src assets/src tests/e2e", "lint:styles": "wp-scripts lint-style 'blocks/src/**/*.scss' 'assets/src/**/*.scss'", "lint:types": "tsc", - "audit": "composer audit && npm audit", "audit:fix": "npm audit fix", "check:engines": "wp-scripts check-engines", diff --git a/src/Settings.php b/src/Settings.php index 34b72c9..533a523 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -51,10 +51,11 @@ public function get_section_fields( string $tab_id, string $section_id ): array 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_FEATURE ] ), ), array( - 'type' => 'text', - 'id' => DWS_TPL_OPTION_GREETING, - 'title' => __( 'Message', 'dws-plugin-template' ), - 'default' => dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), + 'type' => 'text', + 'id' => DWS_TPL_OPTION_GREETING, + 'title' => __( 'Message', 'dws-plugin-template' ), + 'default' => '', + 'placeholder' => dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), // A default would be saved in the admin's language with the first save of the tab. ), array( 'type' => 'number', diff --git a/tests/Integration/Feature/FeatureDetailTest.php b/tests/Integration/Feature/FeatureDetailTest.php index 9d8c945..696158e 100644 --- a/tests/Integration/Feature/FeatureDetailTest.php +++ b/tests/Integration/Feature/FeatureDetailTest.php @@ -108,11 +108,11 @@ public function test_an_unsaved_greeting_prints_its_translated_default(): void { self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); } - public function test_an_empty_greeting_prints_nothing(): void { + public function test_an_empty_greeting_prints_its_translated_default(): void { update_option( DWS_TPL_OPTION_GREETING, '' ); $this->place_orders( 1 ); - self::assertSame( '', $this->print() ); + self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); } protected function place_orders( int $count ): void { diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 1eec308..920d224 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -127,6 +127,10 @@ public function test_a_string_without_a_stored_value_reads_its_translated_defaul self::assertNotSame( '', dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ); self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + update_option( DWS_TPL_OPTION_GREETING, '' ); + + self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + update_option( DWS_TPL_OPTION_GREETING, 'Hello' ); self::assertSame( 'Hello', dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php new file mode 100644 index 0000000..cfd5b2a --- /dev/null +++ b/tests/Integration/PartialDeployTest.php @@ -0,0 +1,87 @@ +&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + $output = \array_values( \array_filter( $output, static fn ( string $line ): bool => '' !== \trim( $line ) ) ); + + return array( + 'status' => $status, + 'output' => \implode( "\n", $output ), + 'last' => (string) \end( $output ), + ); + } +} diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index ccb98b0..5a021e8 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -66,12 +66,22 @@ public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defau self::assertSame( 'DWS Plugin Template', self::$tab->get_label() ); self::assertFalse( $form->getElementById( DWS_TPL_OPTION_FEATURE )?->hasAttribute( 'checked' ) ); - self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) ); + self::assertSame( '', $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) ); + self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), $form->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'placeholder' ) ); self::assertSame( '1', $form->getElementById( DWS_TPL_OPTION_GREETING_ORDERS )?->getAttribute( 'value' ) ); self::assertSame( '1', $form->getElementById( DWS_TPL_OPTION_GREETING_ORDERS )?->getAttribute( 'min' ) ); self::assertFalse( $form->getElementById( DWS_TPL_OPTION_DELETE_DATA )?->hasAttribute( 'checked' ) ); } + public function test_a_save_that_leaves_the_message_untouched_stores_no_translated_text(): void { + delete_option( DWS_TPL_OPTION_GREETING ); + + $this->save( array( DWS_TPL_OPTION_GREETING => $this->render()->getElementById( DWS_TPL_OPTION_GREETING )?->getAttribute( 'value' ) ) ); + + self::assertSame( '', get_option( DWS_TPL_OPTION_GREETING ) ); + self::assertSame( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ), dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + } + public function test_the_retention_opt_in_round_trips_through_woocommerces_save(): void { $this->save( array( DWS_TPL_OPTION_DELETE_DATA => '1' ) ); diff --git a/tests/README.md b/tests/README.md index 9093fa8..3dddf0f 100644 --- a/tests/README.md +++ b/tests/README.md @@ -7,7 +7,7 @@ This document describes the plugin's three test suites, the environments they ru | Suite | Where | Runs on | Covers | | --- | --- | --- | --- | | Unit | `tests/Unit/` | PHP alone, without WordPress | the component tree and the footprint's shape and coverage | -| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, and the assets and blocks | +| Integration | `tests/Integration/` | the tests site, with WordPress, WooCommerce and order tables loaded | the boot, every component, the options and their readers, the migrations, deactivation, uninstall, the assets and blocks, and a partial deploy: a missing build file or engine, or an include that throws | | 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/`. @@ -48,7 +48,7 @@ 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** runs Unit once, and 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. diff --git a/uninstall.php b/uninstall.php index dd28ad4..26b608e 100644 --- a/uninstall.php +++ b/uninstall.php @@ -32,6 +32,9 @@ $dws_tpl_footprint = dws_tpl_get_footprint(); $dws_tpl_consent = DWS_TPL_OPTION_DELETE_DATA; $dws_tpl_meta_types = MetaTypes::DEFINITIONS; + if ( ! class_exists( Uninstaller::class ) ) { + throw new RuntimeException( 'its uninstall engine is missing.' ); + } } catch ( Throwable $error ) { // WP-CLI then fails and keeps the files for a rerun on a complete build, while a web request deletes them and leaves the data. if ( defined( 'WP_CLI' ) && WP_CLI ) {