From 4f3486a9d9dd5be71444a743aed234031cbfa7a7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:58:00 +0100 Subject: [PATCH 01/28] Update packages The @wordpress packages WordPress published on 2026-11-04 had not cleared npm's seven-day cooldown when T-demo refreshed the lock. Each range now starts at the newest release, @wordpress/block-editor at its new major. @wordpress/block-editor 20.0.0 ships its own type declarations, so the hand-written ones in blocks/src/declarations.d.ts no longer describe a missing package and would override the real types. The shim keeps only its SCSS declaration, and the editor script passes its block to insertBlocks() in a list, as 20.0.0 types it; WordPress's insertBlocks() takes one block or a list, so the editor behaves the same (X359). Assisted-by: Claude Code:claude-opus-5-5 --- assets/src/editor.ts | 6 +- blocks/src/declarations.d.ts | 24 - package-lock.json | 864 ++++++++++++++++++----------------- package.json | 16 +- 4 files changed, 456 insertions(+), 454 deletions(-) diff --git a/assets/src/editor.ts b/assets/src/editor.ts index 07ca759..62d57b1 100644 --- a/assets/src/editor.ts +++ b/assets/src/editor.ts @@ -8,9 +8,9 @@ dispatch( commandsStore ).registerCommand( { name: 'dws-plugin-template/insert-example-notice', label: __( 'Insert an example notice', 'dws-plugin-template' ), callback: ( { close } ) => { - dispatch( blockEditorStore ).insertBlocks( - createBlock( 'dws-plugin-template/example-notice' ) - ); + dispatch( blockEditorStore ).insertBlocks( [ + createBlock( 'dws-plugin-template/example-notice' ), + ] ); close(); }, } ); diff --git a/blocks/src/declarations.d.ts b/blocks/src/declarations.d.ts index 38c0da6..9d96335 100644 --- a/blocks/src/declarations.d.ts +++ b/blocks/src/declarations.d.ts @@ -1,26 +1,2 @@ // wp-scripts bundles the styles a block imports, so TypeScript only needs to know the imports exist. declare module '*.scss'; - -// @wordpress/block-editor ships no type declarations, so the hooks the blocks use and the store the editor script -// dispatches to are declared here. -declare module '@wordpress/block-editor' { - import type { BlockInstance } from '@wordpress/blocks'; - import type { ReduxStoreConfig, StoreDescriptor } from '@wordpress/data'; - import type { HTMLAttributes } from 'react'; - - export const store: StoreDescriptor< - ReduxStoreConfig< - unknown, - { - insertBlocks: ( - blocks: BlockInstance | BlockInstance[] - ) => object; - }, - unknown - > - >; - - export function useBlockProps( - props?: HTMLAttributes< HTMLElement > - ): HTMLAttributes< HTMLElement >; -} diff --git a/package-lock.json b/package-lock.json index 0275cde..555cd90 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,14 +12,14 @@ "@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/commands": "^2.0.0", - "@wordpress/data": "^11.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/block-editor": "^20.0.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/commands": "^2.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/env": "^12.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/scripts": "^37.1.0", "typescript": "^6.0.3" }, "engines": { @@ -8171,23 +8171,23 @@ } }, "node_modules/@wordpress/a11y": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-5.0.0.tgz", - "integrity": "sha512-ocYyNn7Z85gDzvto3ppOPeuaj4VoDk03ymsO3ubuAoEDdxXgsTiJIZIBTYrwxvQZ3KznBe5i/HzPKrpvdpJQ+Q==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/a11y/-/a11y-5.1.0.tgz", + "integrity": "sha512-nx1PMKhhIGlayykhq8CqkhioJgti6GvZVFpeKW6yGR+6diDXcRhgLEBth+u6OKnQwOs22rKJjvnvqhE3L+qW6g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/dom-ready": "^5.0.0", - "@wordpress/i18n": "^7.0.0" + "@wordpress/dom-ready": "^5.1.0", + "@wordpress/i18n": "^7.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/autop": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-5.0.0.tgz", - "integrity": "sha512-aHfC5Z7TmwvuRT0iV9e6l/N17C3VVcXcOlzouEC0x2/SNEc0dR71b3WPc0Qx4P44TAvpp7YQPJcaaM/Fz7Bfbg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/autop/-/autop-5.1.0.tgz", + "integrity": "sha512-PhQPcSqJ/tQCkS5hDtHnnnpZfB/cxHXkXrcfmCM8sOMUFF0Xea0MmSMDIT4VyPatdXE8SD2WewSO6IOl1r/JOg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8195,9 +8195,9 @@ } }, "node_modules/@wordpress/babel-preset-default": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.0.0.tgz", - "integrity": "sha512-mXxwcIfEuj10SnofavV13/0NshtkrI+d1S/vSWrtgU5ibj+ejcvAXBiqIPBsb3RZDZwipTbRJV+VbyBjaf4IrQ==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/babel-preset-default/-/babel-preset-default-10.1.0.tgz", + "integrity": "sha512-D59dkRTnhgAoJekTXlXp2oPbUwmiVOC/kiyu6aEh/IJUwK1G9WEpbsvvgsVIWZTJTxGsdGKy3MdcZPW6nKS/FA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8207,8 +8207,8 @@ "@babel/plugin-transform-runtime": "^7.25.7", "@babel/preset-env": "^7.25.7", "@babel/preset-typescript": "^7.25.7", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/warning": "^4.1.0", "browserslist": "^4.28.7", "core-js": "^3.31.0", "react": "^19.3.0" @@ -8218,9 +8218,9 @@ } }, "node_modules/@wordpress/base-styles": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-14.0.0.tgz", - "integrity": "sha512-BkxwZfnaazn6qziF1yBcYPyGziFArrcsyKgm09fDd0SdegRsh2ZmlENwAcvfuQ23ENS/m3vuFMMmBUbqg+Efmg==", + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/base-styles/-/base-styles-15.0.0.tgz", + "integrity": "sha512-9ogLMevcb51DuorQblf7P8AIfN2zoKSyfIgmRmH6WhaB0f8MlzLn2SfF6uKsTgbDKmj5n9X9UakRfO7yQWxLNw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8228,9 +8228,9 @@ } }, "node_modules/@wordpress/blob": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-5.0.0.tgz", - "integrity": "sha512-BB2FpHEOq1my0Qe6BD/K5SAWt5I40vDZaFxH5PeeUyZ1KCEszaidkwYsY02sXxRK8rK/WOHGii2fOcpIqrmajA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/blob/-/blob-5.1.0.tgz", + "integrity": "sha512-pVClxEJWtPalF9O2IBTx9fqJBzpsGx4pArgCvaKkUCymbUKCjT5/ERgk7ooa5YRwjfpO5tZ6GPjC0PBFSPTQUw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8238,50 +8238,50 @@ } }, "node_modules/@wordpress/block-editor": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-19.0.0.tgz", - "integrity": "sha512-HZqOQC10gR7NOU1cUWRrIKx/6XuZBrlbezw8fLgbMpZD91E0oglSqrkvejhE+Ezw8zpDHvD9L/SacfZv8BP/Hw==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-editor/-/block-editor-20.0.0.tgz", + "integrity": "sha512-gYDOcVSUAeEVJAtUqvclNxfmYBZmIWXixRW5n+uR6CICue21TICF0YrxcqnuxFcy7dDhGUdt+XERx4ayFs1RNg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@react-spring/web": "^10.1.2", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/blob": "^5.0.0", - "@wordpress/block-serialization-default-parser": "^6.0.0", - "@wordpress/blocks": "^17.0.0", - "@wordpress/commands": "^2.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/dataviews": "^21.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/global-styles-engine": "^2.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/image-cropper": "^2.0.0", - "@wordpress/interactivity": "^8.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/kebab-case": "^2.0.0", - "@wordpress/keyboard-shortcuts": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/notices": "^6.0.0", - "@wordpress/preferences": "^5.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/style-engine": "^3.0.0", - "@wordpress/token-list": "^4.0.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/upload-media": "^0.43.0", - "@wordpress/url": "^5.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/block-serialization-default-parser": "^6.1.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/commands": "^2.1.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/dataviews": "^22.0.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/global-styles-engine": "^2.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/image-cropper": "^2.1.0", + "@wordpress/interactivity": "^8.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/kebab-case": "^2.1.0", + "@wordpress/keyboard-shortcuts": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/notices": "^6.1.0", + "@wordpress/preferences": "^5.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/style-engine": "^3.1.0", + "@wordpress/token-list": "^4.1.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/upload-media": "^0.44.0", + "@wordpress/url": "^5.1.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", @@ -8291,7 +8291,6 @@ "postcss": "^8.4.38", "postcss-prefix-selector": "^1.16.0", "postcss-urlrebase": "^1.4.0", - "react-easy-crop": "^5.4.2", "remove-accents": "^0.5.0" }, "engines": { @@ -8309,9 +8308,9 @@ } }, "node_modules/@wordpress/block-serialization-default-parser": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-6.0.0.tgz", - "integrity": "sha512-C9HgZeToeeNouMGjTnz5WV8z4vNZ+NBrX4af4Npy6F/VrikOpMAYYJ5hi1K07g/o9hzalZMYN+fHXvuQcYK9MQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/block-serialization-default-parser/-/block-serialization-default-parser-6.1.0.tgz", + "integrity": "sha512-3A++v/Sa0SSre1Rj5EicD2b4Caah4f6gOwAdfcpurEdksUtMYXOrQLL8wDxt8fC1GpEeirB6M4nA8pL6xEHC7g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8319,28 +8318,28 @@ } }, "node_modules/@wordpress/blocks": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-17.0.0.tgz", - "integrity": "sha512-6lZpvRhaFvg/GxYa6RXauU67g7ocjR2UloY1i53wFrCPoNgZAIIfn2q94IX5KeE35nSr0u8XdzDjyRwFz0QGYw==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/blocks/-/blocks-17.1.0.tgz", + "integrity": "sha512-KgqIe1xTCftJmzouALC0xJlpILPgfUfkkN/VrSg8ugwy/HR5woruWlwlgJ8nVAXAfaP7mpb7r+oxnsc5Jjb9KA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/autop": "^5.0.0", - "@wordpress/blob": "^5.0.0", - "@wordpress/block-serialization-default-parser": "^6.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/shortcode": "^5.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/autop": "^5.1.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/block-serialization-default-parser": "^6.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/shortcode": "^5.1.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "colord": "^2.9.4", "fast-deep-equal": "^3.1.3", @@ -8372,9 +8371,9 @@ } }, "node_modules/@wordpress/browserslist-config": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.0.0.tgz", - "integrity": "sha512-eZ2yaOBprRNppFNN/qubJ7C2nOX6llR+IeQ5jQC1665sFcOkZes+hLwpbKsZNcmL26ge+77vdQ0f/Ur67rgkgw==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/browserslist-config/-/browserslist-config-7.1.0.tgz", + "integrity": "sha512-4vPRj28I2wTiGhUoVU9Kirjed+1eurphEjGpf626FlIU0f9sExLMuTWYLjqgU9aVvxkmm1M+CKdrNL0GnSnv9g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8382,22 +8381,22 @@ } }, "node_modules/@wordpress/commands": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-2.0.0.tgz", - "integrity": "sha512-pfkGCvuEOJ6v67++VKdn387pM3AFbAQKVLZJ2lq9Ei75xvd7pkmsnz3iqLA326E88IiXmSY49KSXuU/4bGsCYA==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/commands/-/commands-2.1.0.tgz", + "integrity": "sha512-NBZzSLMNxrfPkyhHLoIbLVgmfBTXn3o4nw5FU+KHf7R7CmnoJsE+thS2SOyEXfEDhNHJbx0fIdeRaqnZMFWg8A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/keyboard-shortcuts": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/preferences": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/keyboard-shortcuts": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/preferences": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", "clsx": "^2.1.1", "cmdk": "^1.0.0" }, @@ -8416,9 +8415,9 @@ } }, "node_modules/@wordpress/components": { - "version": "43.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-43.0.0.tgz", - "integrity": "sha512-8IfVyT0QXTlhcw9id9aSygYISKR+xjyyVWuJuwMGgb/SqHGBFLnBBKBRq/ypamF/eB0vE8Gb+AmdjP6G8P1LBw==", + "version": "44.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/components/-/components-44.0.0.tgz", + "integrity": "sha512-JgRqJm0Tdz5yI0z5i48W3vAinaOSuZvK+GfAcg8yWRF+FATwSgme7xOYSkTmKRKMmK/jbxvW8OIoPyYisZkM5w==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8427,27 +8426,28 @@ "@floating-ui/react-dom": "^2.1.9", "@types/highlight-words-core": "^1.2.1", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/escape-html": "^4.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/html-entities": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/primitives": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/rich-text": "^8.0.0", - "@wordpress/style-runtime": "^0.14.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/escape-html": "^4.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/html-entities": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/primitives": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/rich-text": "^8.1.0", + "@wordpress/style-runtime": "^0.15.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/warning": "^4.1.0", + "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", "csstype": "^3.2.3", @@ -8480,21 +8480,21 @@ } }, "node_modules/@wordpress/compose": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.0.0.tgz", - "integrity": "sha512-xP7EDOTyJrc5fC48Zh/u9Hvu+eHvKlU+qn5OyDQi1ahmg5GqNq3UAaODnsgeLyggGgWtmJhbzgN0qIJvhn52Sw==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-9.1.0.tgz", + "integrity": "sha512-L3YoJWyIgQs50Y4A9qdptjDWdfWjFr9V3TqZIzR/yehBA86UwlmP5iBhM8epQTylDf/RsTttJBm8f37/wQvStw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@types/mousetrap": "^1.6.8", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/undo-manager": "^2.0.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/undo-manager": "^2.1.0", "change-case": "^4.1.2", "mousetrap": "^1.6.5" }, @@ -8517,19 +8517,19 @@ } }, "node_modules/@wordpress/data": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-11.0.0.tgz", - "integrity": "sha512-z74Y4/cfyw+Kel5uhVYsoZTytFtUw2xRrfFpa7uWIdfrdQxSVgxajd74pRjxGQ6W+g0VWzV6ZBptHDxYf58Nmg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/data/-/data-11.1.0.tgz", + "integrity": "sha512-pZnLtXoMGWpq69X3vtppNeW3z/egAAE2LTicVChjTsViXIVYvAhm3eKEWYac4jkxps4vdE9u8B0KUHTtBDum4g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/compose": "^9.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/is-shallow-equal": "^6.0.0", - "@wordpress/priority-queue": "^4.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/redux-routine": "^6.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/is-shallow-equal": "^6.1.0", + "@wordpress/priority-queue": "^4.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/redux-routine": "^6.1.0", "deepmerge": "^4.3.1", "equivalent-key-map": "^0.2.2", "is-plain-object": "^5.1.0", @@ -8556,9 +8556,9 @@ } }, "node_modules/@wordpress/dataviews": { - "version": "21.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-21.0.0.tgz", - "integrity": "sha512-/aojI+0aZEpMYkteZ404V2Bhyxu0Kr5zcf7WhEgmIyHEcfBmuoKIxiPjnVQgAC990gmwW522LYBLnepOymdIOg==", + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/dataviews/-/dataviews-22.0.0.tgz", + "integrity": "sha512-VzL6dVAPyBdciHk8YDIP1dfNSfLep1hOWp8rvpnV4a4RjvSV21QMYR4Fsoje81CAjbdEU7T0s+AOV5BKH7AgPg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8568,22 +8568,22 @@ "@daypicker/react": "^10.0.1", "@floating-ui/react-dom": "^2.1.9", "@use-gesture/react": "^10.3.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/date": "^6.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/hooks": "^5.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/kebab-case": "^2.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/ui": "^0.25.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/date": "^6.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/hooks": "^5.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/kebab-case": "^2.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/ui": "^0.26.0", + "@wordpress/warning": "^4.1.0", "change-case": "^4.1.2", "clsx": "^2.1.1", "colord": "^2.9.4", @@ -8614,13 +8614,13 @@ } }, "node_modules/@wordpress/date": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-6.0.0.tgz", - "integrity": "sha512-knjw0jMLk2RzLq8wUzLuS0lvNI1UqwpbqxYVuSyXrUb+8GKXaT/uq0ql82Dha0RiagNCfrneowZFo3mJc7EyTw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/date/-/date-6.1.0.tgz", + "integrity": "sha512-BhvzGu86dKbZko9ohBgI7/DBKdMb4X33qQKhM45rUib8yME1DcP8F/Y0BRmi5RxdZfoRICp43VaIyV+wVd2qYA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^5.0.0", + "@wordpress/deprecated": "^5.1.0", "moment": "^2.31.0", "moment-timezone": "^0.5.40" }, @@ -8629,9 +8629,9 @@ } }, "node_modules/@wordpress/dependency-extraction-webpack-plugin": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.0.0.tgz", - "integrity": "sha512-SYzb/CWt8VfGGMwGb7UVjBV8qGBkY6PqybUWFtn581lx/KMJANwswLPTWA/QOhog0NSDynscpQuOPibQ5ULceQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dependency-extraction-webpack-plugin/-/dependency-extraction-webpack-plugin-7.1.0.tgz", + "integrity": "sha512-9oFBFJJnJLQl6izhyXDQfFpHUrCNNZ3YUC83AtmwcMpi0AjesuEZ5OYX7SdMof6vRSOG+6UXIvs61Z8S5NswjA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8645,35 +8645,35 @@ } }, "node_modules/@wordpress/deprecated": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.0.0.tgz", - "integrity": "sha512-PZjrzeX15ZNIDvcBzOXnXF/FpKNwZgz23hQ+xpcmRBjZKCyT6YoxC45O3jFIGyZZtBwxoxCYQPBkCjRuKbVcpg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/deprecated/-/deprecated-5.1.0.tgz", + "integrity": "sha512-y56h8AeOVDxXylvVqZRtDjEPO5Z3Kp2ku6XuCnb0j+LeJfTnPhkPN/G1dh07S87b1BzwvI4AYeJa8ZpH/N1eRw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/hooks": "^5.0.0" + "@wordpress/hooks": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/dom": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.0.0.tgz", - "integrity": "sha512-jKRFuQgNuAbFhiIU/b1xQl00rEphMdBQal6pmiN/pDVfIR1idZFaaI8K+eH/Gqn+yb/q20YIqPLM1+SXR4xy8w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom/-/dom-5.1.0.tgz", + "integrity": "sha512-KAul2g72Krm3JWKloiKYdlIsk9uFQYY8FpL2dFFyniSy7kUXo8yVG70YnxSeQfOG1mMWXGog8N6h6OkTTvG8nQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/deprecated": "^5.0.0" + "@wordpress/deprecated": "^5.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/dom-ready": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-5.0.0.tgz", - "integrity": "sha512-a2Y+loEwENAM+kyTnXU7LTe8GgriXWfoxbco4486e2FuNpfELPLjDLAeJVbaFuw8pbtkk6xARlLh32y7yGG7Rg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/dom-ready/-/dom-ready-5.1.0.tgz", + "integrity": "sha512-m+sdUHY4ywFcxmAS0QNTegzLJv7aefwEflRNfjIF6UwLLatuT7S93tnJ4uWr6avB84wsDzVDzCgFlDkJJzGbCA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8681,9 +8681,9 @@ } }, "node_modules/@wordpress/e2e-test-utils-playwright": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.1.0.tgz", - "integrity": "sha512-ybc60l3g1syfpVJ7L/zOculIYBsfVfwsWOAn/bm4vN4+irRT6jJ/11bbPhclb9yjDyGatn+aHw89OhhHLjz8mQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@wordpress/e2e-test-utils-playwright/-/e2e-test-utils-playwright-3.2.0.tgz", + "integrity": "sha512-U7SoahoL8V3UUngjeSYHeKNnQb6EvF70hEzl9rV+Qap6exbNCywo11K0AU7IK0JWgAK5uqVCnC9TVEuYmamdrg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8707,13 +8707,13 @@ } }, "node_modules/@wordpress/element": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.0.0.tgz", - "integrity": "sha512-gv3cZ3O2j4y7qUUBo/h1Ni9OxG6ak78+D0YhggMsGyIPavT4CpJtRyMbarhENJ7K1MM9JzpuecdlMID8Cfd51Q==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-10.1.0.tgz", + "integrity": "sha512-MfHroJjXMcbPZvs8xzewbRJeW4gLnwzI856PqY4SrBnEStkRwzpSbLJW0HEsHaOK1d+Wnu4Hm60amuLLe6WdsQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/escape-html": "^4.0.0", + "@wordpress/escape-html": "^4.1.0", "change-case": "^4.1.2", "is-plain-object": "^5.1.0" }, @@ -8736,9 +8736,9 @@ } }, "node_modules/@wordpress/env": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.0.0.tgz", - "integrity": "sha512-RoUQV3sl1qF4h/kg+vrec9PCfr2bwXSOmWoLd9NmV8rD/sCBMt7tSBfeTWZEbClMhWHce8tu4hJ+pQeHXNnu2A==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/env/-/env-12.1.0.tgz", + "integrity": "sha512-wLodozbP19ON/yV+wDmf8yxbq9NOiacKcaKkLM1wF5c9pO516tTHn5kPrMItyI6B6m/9oULPiywlBUjzfqvF7A==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8764,9 +8764,9 @@ } }, "node_modules/@wordpress/escape-html": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.0.0.tgz", - "integrity": "sha512-fWEvTJvNyPUSAXz3pMETrt1DHKaQ9CJVirhuBqb1xuZmIdRY4AlsjLgayd+lUSTkTU+0gRIPu1TDdU5/6Ue2Nw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/escape-html/-/escape-html-4.1.0.tgz", + "integrity": "sha512-k9lDVTRi2vbTKLyQJDVxNwthTqAmsNSxTVJurDGAdZfXXvbR5QrASTNXXK/sfofqf2wWDFAgI567rhKiAKiYmw==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8774,16 +8774,16 @@ } }, "node_modules/@wordpress/eslint-plugin": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.0.0.tgz", - "integrity": "sha512-VRFrez67J9HWGR4s149A3ZG1ynY9uEczmuR7SFXzlcUn3itlo5ood42pv1dgTZK3eqJo3l5tdK3hD94W+ZEq8w==", + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/eslint-plugin/-/eslint-plugin-28.1.0.tgz", + "integrity": "sha512-u2yK55Bx/ycVn6ZSYim73pxE2gscNVUGF22amHD6sY/vGU9SpQPg0By0WJPmPtXsMBNDhNEYPmDN95MKTxb8bg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@eslint/compat": "^2.0.0", "@vitest/eslint-plugin": "^1.6.27", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/theme": "^3.0.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/theme": "^4.0.0", "cosmiconfig": "^7.0.0", "eslint-config-prettier": "^10.0.0", "eslint-import-resolver-typescript": "^4.4.4", @@ -8817,17 +8817,17 @@ } }, "node_modules/@wordpress/global-styles-engine": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-2.0.0.tgz", - "integrity": "sha512-lhMen98kG/nLv3QPD8HWXPdhuXDbOje7/gYpljv4zKBjAf3tfbRQ11MblKW8OvYUux3Iu9vaZE+DB2LOgNee5g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/global-styles-engine/-/global-styles-engine-2.1.0.tgz", + "integrity": "sha512-7fvMtp5V9SDT8hP/ju9kwdwlobTXNKoSwhNgVystKtfMndaGSLG/8QSsnZ+29lIhEqVwlDuzkScpIHu1PMG2iA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blocks": "^17.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/style-engine": "^3.0.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/style-engine": "^3.1.0", "colord": "^2.9.4", "deepmerge": "^4.3.1", "fast-deep-equal": "^3.1.3", @@ -8853,9 +8853,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.0.0.tgz", - "integrity": "sha512-jA2hDMNr2Ky/D14s8K+TQ/ZI125icjZfTPZYcsgEHM6mTtSJY2dhcmp9+tfSrcnY+M17svzplIxXma+bpBzcFg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-5.1.0.tgz", + "integrity": "sha512-EvHdv0beYOcINcY91jj+brsoLu87AEp2k7gzJC/Xdux3Ng0WLk2VL9A66vpsyZ61J1/Uiivmo3zR/LhC3Y0UzA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8863,9 +8863,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-5.0.0.tgz", - "integrity": "sha512-rj95Op3y+4+QsdOzlguOT8lpAHegJwdMsDx9Eq/9/fL/CGskgugUm4aqqeFQDNd7B+gOzXM7NZMX2ohaYYXYdA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-5.1.0.tgz", + "integrity": "sha512-/zfBha0FxzoZC5LAhzD+EodEmqEE04g988wBhgIL83uaQV+5/pNWdbOlafbtaUpwZlIVQAUJr9mw+GDmuLiGQA==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8873,14 +8873,14 @@ } }, "node_modules/@wordpress/i18n": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.0.0.tgz", - "integrity": "sha512-xk7qKLDB08c3LDUtThfmuumq//354bXLGRrPWfC1gmwPMNJPRC7ggwtSFxzH+ImdssxIKk8NaG4H3yF5WJoS5w==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-7.1.0.tgz", + "integrity": "sha512-WCwJZLUlyI5rbdCBdBDxPGgD405JjiQPzyHRcqS2FwZZDb1+dFnMuDBgeewzWKEtsXlOLJnrJQWKggNJVXnOKQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^5.0.0", + "@tannin/sprintf": "^1.3.4", + "@wordpress/hooks": "^5.1.0", "gettext-parser": "^1.3.1", "tannin": "^1.2.0" }, @@ -8892,14 +8892,14 @@ } }, "node_modules/@wordpress/icons": { - "version": "19.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-19.0.0.tgz", - "integrity": "sha512-LPx0Tppj5Y/54fuqIRMUqzplP0ZQL1nNvWthUv+ctpylDbGe4qdlWJpLCLZq1BpI1IXS2s3g5NaN2dXFi5H45w==", + "version": "20.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/icons/-/icons-20.0.0.tgz", + "integrity": "sha512-dCduwT+fHydn4yKSPCq3L8fsKhOokOkVjX6mIj7x17fuDwYAOCpXR4fZ6KB73V4+UZjP2csbot/WDJBRTzuWBA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^10.0.0", - "@wordpress/primitives": "^5.0.0", + "@wordpress/element": "^10.1.0", + "@wordpress/primitives": "^5.1.0", "change-case": "^4.1.2" }, "engines": { @@ -8921,15 +8921,15 @@ } }, "node_modules/@wordpress/image-cropper": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/image-cropper/-/image-cropper-2.0.0.tgz", - "integrity": "sha512-Ach0WfHZbwM8oomQtgHsTMgG0+wuhlZYD7ZHsygREkPVD9f0Kd+513rtRFfMdQ2NJzdjqJXSD2nEK0hT/gGjOQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/image-cropper/-/image-cropper-2.1.0.tgz", + "integrity": "sha512-XwELLxV8JJBOGCwWLerzDsaN8VhlEpCZHrBlSysD+T/jr6sftVNp6GpmfXuyD8WAd4XhzvfmiqMMS3vuW5ZxbQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/components": "^43.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", "dequal": "^2.0.3", "react-easy-crop": "^5.4.2" }, @@ -8948,9 +8948,9 @@ } }, "node_modules/@wordpress/interactivity": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-8.0.0.tgz", - "integrity": "sha512-VI60fYRVsSErIs/K9xnUNoAXUycgQwMGP7cq7YHvWdKROPE285+lnv9GarRGFR7W5/9i5fk3a8ifV+2o6sMCOg==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/interactivity/-/interactivity-8.1.0.tgz", + "integrity": "sha512-BT2xZiWF963y8l1agiTGQXVKcdBip1gOXlH2ZzSmstILBVHu56zgX5Mg8GJ5ZiImkpjYgYRrxJ96EBfPTnv9og==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8963,9 +8963,9 @@ } }, "node_modules/@wordpress/is-shallow-equal": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.0.0.tgz", - "integrity": "sha512-KMEZTCaXWp5udPNdPc8lEky3AEhKnUej0aKfjV38Ggox2e87SDXALt3DdMhydO//2xlXZXfGtYtBJ0qk9LpnLQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-6.1.0.tgz", + "integrity": "sha512-3zZw6SnVLPCfxHxHo7nlg6lRzKn7te/h4al2kdZ3GMWUAdtRsiJZKgiJcVCqNlJN7tbOyNXUepbVXyGJhrTE3Q==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -8973,9 +8973,9 @@ } }, "node_modules/@wordpress/kebab-case": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/kebab-case/-/kebab-case-2.0.0.tgz", - "integrity": "sha512-JjPgCJ5SRbbiKboK1U+k37NY8Hb42hHzMphczPtYPGGRNiasMV7T4RkbKlvUo+Qcm0hE6G8ct9VnEsfSsEbjoQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/kebab-case/-/kebab-case-2.1.0.tgz", + "integrity": "sha512-3Rsnx3l6uCvqSeZdnvXBLcV2boYDeKHscrTsoEs/PiJAMj+Kaa/AtFGqIUs2sD7ys5taD4Y5QnM3Nf0W3KfKXA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -8986,15 +8986,15 @@ } }, "node_modules/@wordpress/keyboard-shortcuts": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-6.0.0.tgz", - "integrity": "sha512-6OkdL+ZFJ+0llQtiDkP5OifzKvX7NMJpwsbBssm9vfNa93okyLOWY3j8HzUyKQLnuSrO8z+SwBzSJjImUHorTg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/keyboard-shortcuts/-/keyboard-shortcuts-6.1.0.tgz", + "integrity": "sha512-8sLzTrGbIhLACP0IXnD2NqwRC5i1Syi1g7LlpR5+nd3r4akLYvFRv1M7AfVN4Dkfgz8U0gsdaVu+hsLVhbvd+Q==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/keycodes": "^5.0.0" + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/keycodes": "^5.1.0" }, "engines": { "node": ">=22.13.0" @@ -9015,13 +9015,13 @@ } }, "node_modules/@wordpress/keycodes": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.0.0.tgz", - "integrity": "sha512-15T931kJjj90KlDcsXKaGucK+iyqY9CftR7tXO60m8T8gDC2GVuUWMIX3Sq9+4fsahABuqoUd+QTiW4xSDlpCg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/keycodes/-/keycodes-5.1.0.tgz", + "integrity": "sha512-bqelkvytmYitRbE/3LvMw2xHHFVgq+WYHfv6CiQ+O2HwOES45fuPYhCw6r05yZO5zEg+nh0dB0X4bfeR8kwAsw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^7.0.0" + "@wordpress/i18n": "^7.1.0" }, "engines": { "node": ">=22.13.0" @@ -9036,21 +9036,21 @@ } }, "node_modules/@wordpress/notices": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-6.0.0.tgz", - "integrity": "sha512-IDiPfOgUvswr1aXVm3SwOziTtZY+R8t0CfO0jHzK1M50ddzRRTReZPM1bPftU23PAW5k1UqxPc/aYRwLK4Xopg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-6.1.0.tgz", + "integrity": "sha512-W+2+GJg2w6RoocMrXjoK7JJGOxISlOcRUd0Dxwz/xAvMGgVMy1Sh/1Aq7OMGWgFeemWBeClJ1H0icrPE+YlNGw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/ui": "^0.25.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/ui": "^0.26.0", "clsx": "^2.1.1" }, "engines": { @@ -9072,9 +9072,9 @@ } }, "node_modules/@wordpress/npm-package-json-lint-config": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.0.0.tgz", - "integrity": "sha512-h0VN1RW3OqsreiFrJ13FD/jFX9b+VHerfWNED/OUUSi17pmVgik47niTfth1/xpotL+kz2RuBopb9oV+HpKcQQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/npm-package-json-lint-config/-/npm-package-json-lint-config-6.1.0.tgz", + "integrity": "sha512-72fpK1zMW3X9xSarhweHqoBOiIYJD4FWGTkjrTYxUWF3DvR0wmKNqbL2mpzL7aD7tCBVEPN8tKhhDAqdnnS26g==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9085,13 +9085,13 @@ } }, "node_modules/@wordpress/postcss-plugins-preset": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.0.0.tgz", - "integrity": "sha512-Z8PLYiY32e0k7xQz0Fc7K30PcOIFvaoc9JduPMQMCfmukJKFMaRYxqyJZNdva7fPu18vUl9dFpB6ZP/+C5aZYQ==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/postcss-plugins-preset/-/postcss-plugins-preset-6.1.0.tgz", + "integrity": "sha512-oOGrqTX0v+M44V/nliAS0OUr1Mmtn6TfMBO9zll0DP7/+CdPyEdRcNoTjicXWhd/DnYfAsetcBBtSBaBSLJTMg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/browserslist-config": "^7.0.0", + "@wordpress/browserslist-config": "^7.1.0", "autoprefixer": "^10.4.21", "postcss-import": "^16.1.1" }, @@ -9103,22 +9103,22 @@ } }, "node_modules/@wordpress/preferences": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-5.0.0.tgz", - "integrity": "sha512-rQaSz2di9k3tzm9BkO26ikydgevBDf+QU9ET97rFjlSaoDqQ1YqOLcFH61o8vtthr3NjQFBq/XAz38VMCVVsXw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/preferences/-/preferences-5.1.0.tgz", + "integrity": "sha512-YSsyvXgGlhuOIqFTBd65C3uSesCD2FvD522iWbfSjZyma31CAYHts0iL7pEjRkvGoT+grgjQ7IqShpgJxVN6nQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/base-styles": "^14.0.0", - "@wordpress/components": "^43.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/private-apis": "^3.0.0" + "@wordpress/a11y": "^5.1.0", + "@wordpress/base-styles": "^15.0.0", + "@wordpress/components": "^44.0.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/private-apis": "^3.1.0" }, "engines": { "node": ">=22.13.0" @@ -9135,9 +9135,9 @@ } }, "node_modules/@wordpress/prettier-config": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.0.0.tgz", - "integrity": "sha512-uQ3rtrqt8yvUHFN9Xcxuw7EfSw4ArMkwFxtz2RHZM2Sis9jzB7xk0FcK/iWE55q0WWSjmFGWoNVpOEQoSE7ocQ==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/prettier-config/-/prettier-config-5.1.0.tgz", + "integrity": "sha512-Ep67YhwkEVAO2i+7lbzNqQrvUlSFY1oecth7WehXvp2jq0X2X/aDNEMOPxbY0sf6KKmYaWrRiu5ECOqB2QgJ7w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9148,13 +9148,13 @@ } }, "node_modules/@wordpress/primitives": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-5.0.0.tgz", - "integrity": "sha512-XrRJIbNXai/jlL9FLEANUzaAigTdN8XgWzAHTfAjDskvy3X6CjUbMyjXLWE5OhyCKhq+JAd3mWb9zq4sgl+V2w==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/primitives/-/primitives-5.1.0.tgz", + "integrity": "sha512-SAZJ2acNDZSGBe2UnJYfpt8pswkiNyZ1hPBbd+yCTmZezPequaCtnRImXl6rtdxlIbfG/1y2nGsn190I6xcWow==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/element": "^10.0.0", + "@wordpress/element": "^10.1.0", "clsx": "^2.1.1" }, "engines": { @@ -9176,9 +9176,9 @@ } }, "node_modules/@wordpress/priority-queue": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.0.0.tgz", - "integrity": "sha512-YXEwn7xtr4bUts2IOPREjlWYa3n/ba7ndLCqFN3ofeRvvr2ZVCI1WP20v7a+CQZi+n9FS7fy+lYqx3wnTYtGzg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/priority-queue/-/priority-queue-4.1.0.tgz", + "integrity": "sha512-Xt9O99LLh3YT5LVXsxsGrCJDOuGCv61qK6dWZLitCIg/BxCPc/ihfV98lm4Fupye57439vqPIHKpjV3vJEmUBg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9189,9 +9189,9 @@ } }, "node_modules/@wordpress/private-apis": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.0.0.tgz", - "integrity": "sha512-IDXGi2VWfjUFwBTDtWj7/FMY240JpqraxFEIaXIeD3KFumX8lQuAYvE9gU8GcNBt/NlXZCDEmNioxCbAZN+jiA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/private-apis/-/private-apis-3.1.0.tgz", + "integrity": "sha512-Zj9LAP8+Z+2tihAEPNniS6+t0oYSEEbkQ4uHtvzvZpaD+UM9EAmThpMPKR4YP9l4XMb/Y4W/fiMSNm1mDnPY1w==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9199,9 +9199,9 @@ } }, "node_modules/@wordpress/react-inert-value": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/react-inert-value/-/react-inert-value-2.0.0.tgz", - "integrity": "sha512-inwoViBZ2K3ubTvfjkvpzwpXovZVF05CMWZ08scQt+ttYHD1Sf3pL3gn11iAxcR1Wf/0TzZoPeLc+PhnAUzuSg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/react-inert-value/-/react-inert-value-2.1.0.tgz", + "integrity": "sha512-JBx38pTvCCbrY28/UKExlxkr6fQtFNoX+Apk2+bDXg9rewDyr5sgWBJQqbKXLS5XsM7Xfgs9y/knmvRR6zmzcg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9218,9 +9218,9 @@ } }, "node_modules/@wordpress/redux-routine": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-6.0.0.tgz", - "integrity": "sha512-mJm9DeHXFIPeaCA6E+VYupWXBW7g1QuKqEgBclm3HCDN+Th6OO+HMLfa2eS+dAPyoTu2NhSF0QlY1ArijOzuiw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/redux-routine/-/redux-routine-6.1.0.tgz", + "integrity": "sha512-Oayhin+I/u8e5lH99ey7+Vj+8ww6rkLkYGhFwFB3gdl+KBN8a9MTua3WwLZDDjN4Eail+NZCJWy4/bkSOzIKiA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9236,22 +9236,22 @@ } }, "node_modules/@wordpress/rich-text": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-8.0.0.tgz", - "integrity": "sha512-2Jg50SJwE82+qO8tnQbIhSqCIb9I+rW6bi0hRVcTMOtRxC8wBisw5n0zngLfGRSHtAA9irnEzrOfPk2m6LtyuQ==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/rich-text/-/rich-text-8.1.0.tgz", + "integrity": "sha512-UpIpVlRN1w4XC1tirXebMvJ/stJezL6Z7wWq+luS0+1FwYz1Kelq92ZXEWykpKK7odlJYYvDiqWBhWMrZjpNZw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/a11y": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/dom": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/escape-html": "^4.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/dom": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/escape-html": "^4.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", "colord": "^2.9.4" }, "engines": { @@ -9273,24 +9273,24 @@ } }, "node_modules/@wordpress/scripts": { - "version": "37.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.0.0.tgz", - "integrity": "sha512-/a01kTH8lqAPzO6AhZRBiFpWcDorZs32U07YdkNhV7UOQAhWrBAWBOXPsLBcCh7NRt603C/hYOea8YcIqPpbZA==", + "version": "37.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/scripts/-/scripts-37.1.0.tgz", + "integrity": "sha512-75ZJTN+uAp9ESxS43hbQjYJRjHFUb0gTF1SQ53iDsX5MxjDjQRzdnNpNMw5peYz5Sv4WE7/l3Hfg3WkVm5U3sg==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@babel/core": "^7.25.7", "@pmmmwh/react-refresh-webpack-plugin": "^0.6.3", "@svgr/webpack": "^8.0.1", - "@wordpress/babel-preset-default": "^10.0.0", - "@wordpress/browserslist-config": "^7.0.0", - "@wordpress/dependency-extraction-webpack-plugin": "^7.0.0", - "@wordpress/e2e-test-utils-playwright": "^3.1.0", - "@wordpress/eslint-plugin": "^28.0.0", - "@wordpress/npm-package-json-lint-config": "^6.0.0", - "@wordpress/postcss-plugins-preset": "^6.0.0", - "@wordpress/prettier-config": "^5.0.0", - "@wordpress/stylelint-config": "^27.0.0", + "@wordpress/babel-preset-default": "^10.1.0", + "@wordpress/browserslist-config": "^7.1.0", + "@wordpress/dependency-extraction-webpack-plugin": "^7.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/eslint-plugin": "^28.1.0", + "@wordpress/npm-package-json-lint-config": "^6.1.0", + "@wordpress/postcss-plugins-preset": "^6.1.0", + "@wordpress/prettier-config": "^5.1.0", + "@wordpress/stylelint-config": "^27.1.0", "adm-zip": "^0.6.1", "babel-loader": "^9.2.1", "browserslist": "^4.28.7", @@ -9570,9 +9570,9 @@ "license": "MIT" }, "node_modules/@wordpress/shortcode": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-5.0.0.tgz", - "integrity": "sha512-anwstb/SfweKgw8BNEouvzwCII9AAY7I/asPZJf084rEtG/5V2II9ls0PdfamYuDedNvFdToCV3uiK9NFD06jw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/shortcode/-/shortcode-5.1.0.tgz", + "integrity": "sha512-o5Rjf21cXSI5cOX3MhjkdMyr1nUtpIp6X/3IdSK0PoB4SOfr4pTu6O7QqFG3/UuAgkuAX+STtqKla2zJ80aYow==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9583,9 +9583,9 @@ } }, "node_modules/@wordpress/style-engine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-3.0.0.tgz", - "integrity": "sha512-obU8yuXphfCZVdcv7bVf/dn6AlzBqYfglyyc/ymqEyjmh4cTY0pF/Do1sraT/XP5n+NZrFGV5D9wcJILTNC3Lg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-engine/-/style-engine-3.1.0.tgz", + "integrity": "sha512-sqUKzTn7ZEoSAaTCusMUAU2cRhJihPtKydSkQI/luraOhDxq3m4ZRBn38xTo14AU+ji0S5HRfk9XXWOL3AkRBA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9604,9 +9604,9 @@ } }, "node_modules/@wordpress/style-runtime": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.14.0.tgz", - "integrity": "sha512-cWk3G7Pe6Wo9uMpaPAbrhsPJAmkShPn3LLc8vkte1ZQ7ls1M+e6m78KQxd0HYy4ty2nbGGlhnHE7ISxQzGhrsQ==", + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@wordpress/style-runtime/-/style-runtime-0.15.0.tgz", + "integrity": "sha512-Rc8D0DK17I7OfG1KbMVw3E8SC+84D4LRMnHq0eHbNwilBTogGiieo0MWx1j6Bzkf82Q5n5JCPct+zm0W0Gk84A==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9614,14 +9614,14 @@ } }, "node_modules/@wordpress/stylelint-config": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.0.0.tgz", - "integrity": "sha512-NvLTijt+fZR2/0T3EwH5cswLdpuo/On1B+BQlkkuQ3HsNrwWLTEvwboaOqzJUwFs/owB9+AO11Ocaayexesalw==", + "version": "27.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/stylelint-config/-/stylelint-config-27.1.0.tgz", + "integrity": "sha512-AAV9GMDyRCnHKfmntb8C40u1zSDIkV1HotHMD3CK0Om+VX1aYVjkdRJikSzvcYEcaS6zK8KaKpDFlSvvNbdcXQ==", "dev": true, "license": "MIT", "dependencies": { "@stylistic/stylelint-plugin": "^5.2.1", - "@wordpress/theme": "^3.0.0", + "@wordpress/theme-build-tools": "^1.0.0", "stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended-scss": "^17.0.1" }, @@ -9629,14 +9629,55 @@ "node": ">=22.13.0" }, "peerDependencies": { + "@wordpress/theme": "^3.0.0 || ^4.0.0", "stylelint": "^17.14.1", "stylelint-scss": "^7.2.0" + }, + "peerDependenciesMeta": { + "@wordpress/theme": { + "optional": true + } } }, "node_modules/@wordpress/theme": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-3.0.0.tgz", - "integrity": "sha512-m25ORtLD77WL40vG3Vt/0WKzg/liuiM2PGMNaDv4SDO1hZ7H0t+Lp8rfTlsYnBdI+iO8+juICyK4OiVRde1I0Q==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme/-/theme-4.0.0.tgz", + "integrity": "sha512-VKlIu/ulNrJjeNvWw50HD3x2wKq/ntGQnir5ep0r5DOjpdYYEV0/oGXQSQBARcd4H6TncKX5NzaGL0aBYQq7sg==", + "dev": true, + "license": "GPL-2.0-or-later", + "dependencies": { + "@wordpress/compose": "^9.1.0", + "@wordpress/deprecated": "^5.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/style-runtime": "^0.15.0", + "colorjs.io": "^0.7.1", + "memize": "^2.1.1" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "@types/react": "^18 || ^19", + "react": "^18 || ^19", + "react-dom": "^18 || ^19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@wordpress/theme-build-tools": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/theme-build-tools/-/theme-build-tools-1.0.0.tgz", + "integrity": "sha512-4oy+LYzAokRTentySXjyza7RCMUJG8Ul6mLNTOC+hcwCdwo7yzR3SMhyJfe4uFPON1CNk+s2claCmiIHZnRSPQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -9644,30 +9685,21 @@ "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "@jridgewell/trace-mapping": "^0.3.31", - "@wordpress/compose": "^9.0.0", - "@wordpress/deprecated": "^5.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/style-runtime": "^0.14.0", - "colorjs.io": "^0.7.1", - "magic-string": "^0.30.21", - "memize": "^2.1.1" + "magic-string": "^0.30.21" }, "engines": { "node": ">=22.13.0" }, "peerDependencies": { - "@types/react": "^18 || ^19", + "@wordpress/theme": "^3.0.0 || ^4.0.0", "esbuild": ">=0.27.2 <1.0.0", "lightningcss": ">=1.33.0 <2.0.0", "postcss": "^8.0.0", - "react": "^18 || ^19", - "react-dom": "^18 || ^19", "stylelint": "^16 || ^17", "vite": "^7 || ^8" }, "peerDependenciesMeta": { - "@types/react": { + "@wordpress/theme": { "optional": true }, "esbuild": { @@ -9679,12 +9711,6 @@ "postcss": { "optional": true }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, "stylelint": { "optional": true }, @@ -9694,9 +9720,9 @@ } }, "node_modules/@wordpress/token-list": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-4.0.0.tgz", - "integrity": "sha512-W8WY1B4POIXLQcWM/N3Zz9TGMMqtfJvS2VcIql889eEFKk1trx0ynsbzVFO/UblGiSjAdxpANES9yHNKa43VWw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/token-list/-/token-list-4.1.0.tgz", + "integrity": "sha512-r8DCgJDIyGFVufgSedJ9vAhZQWJCNo0Evy/OTcFwVwRxYxi4X753bOZIcmb+VnIc7+rxxWes3K8VCmmUI0wKew==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9704,26 +9730,26 @@ } }, "node_modules/@wordpress/ui": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.25.0.tgz", - "integrity": "sha512-G/aUYgQpL18q3DfyV/1ebB8tEjXBubWPPXitcs4IvXYbAAFNBLsoUUGv5K3sfqJuxhi51lbyKT0Eap6Wj0kkCQ==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@wordpress/ui/-/ui-0.26.0.tgz", + "integrity": "sha512-ET2RmxE/nfwRpq1fip1+YATtuLOrOyJJq6hRDDHTyC+8iToM/IbVg8dj/ES6zwlW8/UAEStvSj6IkmQuDvc3mA==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { "@base-ui/react": "^1.9.0", "@daypicker/react": "^10.0.1", - "@wordpress/a11y": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/icons": "^19.0.0", - "@wordpress/keycodes": "^5.0.0", - "@wordpress/primitives": "^5.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/react-inert-value": "^2.0.0", - "@wordpress/style-runtime": "^0.14.0", - "@wordpress/theme": "^3.0.0", - "@wordpress/warning": "^4.0.0", + "@wordpress/a11y": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/icons": "^20.0.0", + "@wordpress/keycodes": "^5.1.0", + "@wordpress/primitives": "^5.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/react-inert-value": "^2.1.0", + "@wordpress/style-runtime": "^0.15.0", + "@wordpress/theme": "^4.0.0", + "@wordpress/warning": "^4.1.0", "clsx": "^2.1.1", "date-fns": "^4.4.0", "tabbable": "^6.4.0" @@ -9743,33 +9769,33 @@ } }, "node_modules/@wordpress/undo-manager": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.0.0.tgz", - "integrity": "sha512-OQfGPUBz79/NKkkBvF5L/k1DmxuCQGYvczA/BOef+ECCCnjpIXF6HS1txpULozI5owejQDDjhZp25bq25jF5Vg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/undo-manager/-/undo-manager-2.1.0.tgz", + "integrity": "sha512-n4c6/9V51sNzche2wJReKVUm7c/9ChZsg52939FJuITcLBp5MnR+oEeSe2RQPP4Mq1x3EQAFuM2HibaEnliqvQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/is-shallow-equal": "^6.0.0" + "@wordpress/is-shallow-equal": "^6.1.0" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/upload-media": { - "version": "0.43.0", - "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.43.0.tgz", - "integrity": "sha512-Cgi0XNoWurFFbCJPljPaii2cy7hHwMml3Dbf9zhlUsyihT3H9LfkSR/Oox+NHFPOqAPv1q2YPWCGuZe3mXDZEQ==", + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@wordpress/upload-media/-/upload-media-0.44.0.tgz", + "integrity": "sha512-/ML4kvdhnVxNPv9o98yA0PagnJ708Efrd/jiL+/9rDkgQbnYi+ahNJWeRxXlAUwTEoUacGWy64rTZEmjifiD1g==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/blob": "^5.0.0", - "@wordpress/compose": "^9.0.0", - "@wordpress/data": "^11.0.0", - "@wordpress/element": "^10.0.0", - "@wordpress/i18n": "^7.0.0", - "@wordpress/private-apis": "^3.0.0", - "@wordpress/video-conversion": "^0.9.0", - "@wordpress/vips": "^5.0.0", + "@wordpress/blob": "^5.1.0", + "@wordpress/compose": "^9.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/element": "^10.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/private-apis": "^3.1.0", + "@wordpress/video-conversion": "^0.10.0", + "@wordpress/vips": "^5.1.0", "uuid": "^14.0.0" }, "engines": { @@ -9787,9 +9813,9 @@ } }, "node_modules/@wordpress/url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-5.0.0.tgz", - "integrity": "sha512-oPDn3vjO7unimaiFSnwoP8Ys6TVGp3UXwF0CwRDMoYLqRIjDuPpZLoUbkPAnWnLOrNP+GjIzCm7qQNCrze/UAA==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-5.1.0.tgz", + "integrity": "sha512-D46oS8q/g8kK4diyAtwTvFOY43jONdJsx7qOtoWfhpVyvMGemoiNu+MJCswH2XIUG7oTRML9YrNT50mEoCaXLQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9797,13 +9823,13 @@ } }, "node_modules/@wordpress/video-conversion": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@wordpress/video-conversion/-/video-conversion-0.9.0.tgz", - "integrity": "sha512-fxU5BDB809r/AbxM70LUst3CjWpn85ugT6QebwugOlX+s7aUQ7/HZ5ON0XZE4V2nPtOeUsT1HyVDcikcbaMZbQ==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@wordpress/video-conversion/-/video-conversion-0.10.0.tgz", + "integrity": "sha512-T9vX54HzeZumOQYcvMZky2BnPAuyWYiOD8ksvZuNyBQBr0ImldWIlorm1Q6Etab8G19Vu+jw2/wCzDB3v3SqeQ==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/worker-threads": "^2.0.0", + "@wordpress/worker-threads": "^2.1.0", "mediabunny": "^1.45.2" }, "engines": { @@ -9811,23 +9837,23 @@ } }, "node_modules/@wordpress/vips": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/vips/-/vips-5.0.0.tgz", - "integrity": "sha512-7he8zRvtoaI+uGCoQ6Tf5+PbuhB0OowB0h8H4HLB7yv8hl4YOxnGG1tB1ldB6gypNyukcawk+aK/CcJ2+cnI2A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/vips/-/vips-5.1.0.tgz", + "integrity": "sha512-P3ocGmDQgrGtwE9T2if47LHGTI/mVDuHC+J0EoeQM546fzxYQFXkBCR76UDFEV+kAXmoTX4U7vjP5pAyjMC3Ig==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/worker-threads": "^2.0.0", - "wasm-vips": "^0.0.18" + "@wordpress/worker-threads": "^2.1.0", + "wasm-vips": "^0.0.19" }, "engines": { "node": ">=22.13.0" } }, "node_modules/@wordpress/warning": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.0.0.tgz", - "integrity": "sha512-qhAwfjMUYT1TfyMlrkO4i1IbcqfjfjLBFepXSRvMUhNbL8CRRu3cgjdZNCf82FkvUwmuCEuMsrmGisxu4CGr1A==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/warning/-/warning-4.1.0.tgz", + "integrity": "sha512-nKzIGM+H4YIaGIQY4+KTi6pSRWC4rVm0SKz3TUbORp5WXXq18MsiUqvG4D0vq/fuzo5fhGC5/Ee/T+EPDQF9FQ==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -9835,9 +9861,9 @@ } }, "node_modules/@wordpress/worker-threads": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@wordpress/worker-threads/-/worker-threads-2.0.0.tgz", - "integrity": "sha512-ytIahXlVLdMMDfdDvOl1X4GrzVRcxc4JB+41zWJFdkNnzxDe6xbuyKujNe6kwrWFvr2YfC41wXfPViHEzB96pw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@wordpress/worker-threads/-/worker-threads-2.1.0.tgz", + "integrity": "sha512-4xj15StZQJbQwb0WqvPRS5dmwozlTb45JHd8KH6TQrVKGoyQN+fKm44kXxyeGwboNrdBZDRzNNiiBiiVVg0gtw==", "dev": true, "license": "GPL-2.0-or-later", "dependencies": { @@ -24644,9 +24670,9 @@ "license": "Apache-2.0" }, "node_modules/wasm-vips": { - "version": "0.0.18", - "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.18.tgz", - "integrity": "sha512-AJyCvxZj/3qceKNnh+YyEobu/IaJFoPN7x7SxyyHmYBS3kASMqJqxQEuN0ZHKQDWsCJ8armfx4Tq3uKrNc+nMA==", + "version": "0.0.19", + "resolved": "https://registry.npmjs.org/wasm-vips/-/wasm-vips-0.0.19.tgz", + "integrity": "sha512-KeejYs8oNqPUd2C/XPRhQtRabydqOSkUyCdWhFf2ucL8rVKqHvtp5ZYuYLyeDG/4y45tvROST3mQWla+1uotSA==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index e5c485b..350eb1b 100644 --- a/package.json +++ b/package.json @@ -18,14 +18,14 @@ "@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/commands": "^2.0.0", - "@wordpress/data": "^11.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/block-editor": "^20.0.0", + "@wordpress/blocks": "^17.1.0", + "@wordpress/commands": "^2.1.0", + "@wordpress/data": "^11.1.0", + "@wordpress/e2e-test-utils-playwright": "^3.2.0", + "@wordpress/env": "^12.1.0", + "@wordpress/i18n": "^7.1.0", + "@wordpress/scripts": "^37.1.0", "typescript": "^6.0.3" }, "scripts": { From 81eaeb517cac6d2c256b64992efe96cd376da9d1 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:58:30 +0100 Subject: [PATCH 02/28] Drop the stylesheet registrar's media parameter dws_tpl_register_style() took a media argument that no caller in the template or its ports passes; only AssetsTest's row did. A port copies includes/assets.php whole, so an unused parameter there reaches every plugin. The registrar now leaves media to WordPress's default, and its row asserts that default (X147). Assisted-by: Claude Code:claude-opus-5-5 --- includes/assets.php | 5 ++--- tests/Integration/AssetsTest.php | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/includes/assets.php b/includes/assets.php index d17176a..a949af3 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -65,12 +65,11 @@ function dws_tpl_register_script( string $handle, string $path, array $args = ar * * @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. - * @param string $media Optional. The media the stylesheet applies to. Default 'all'. * * @return bool */ -function dws_tpl_register_style( string $handle, string $path, string $media = 'all' ): bool { - $registered = wp_register_style( $handle, plugin_dir_url( DWS_TPL_FILE ) . $path, array(), dws_tpl_get_asset( $path )['version'], $media ); +function dws_tpl_register_style( string $handle, string $path ): bool { + $registered = wp_register_style( $handle, plugin_dir_url( DWS_TPL_FILE ) . $path, array(), dws_tpl_get_asset( $path )['version'] ); if ( $registered ) { wp_style_add_data( $handle, 'rtl', 'replace' ); } diff --git a/tests/Integration/AssetsTest.php b/tests/Integration/AssetsTest.php index 88fffd7..2282f29 100644 --- a/tests/Integration/AssetsTest.php +++ b/tests/Integration/AssetsTest.php @@ -34,12 +34,12 @@ public function test_a_script_registers_with_its_generated_dependencies_and_vers } public function test_a_stylesheet_registers_with_its_generated_version_and_its_right_to_left_variant(): void { - self::assertTrue( dws_tpl_register_style( self::HANDLE, 'tests/Fixtures/built.css', 'print' ) ); + self::assertTrue( dws_tpl_register_style( self::HANDLE, 'tests/Fixtures/built.css' ) ); $style = wp_styles()->registered[ self::HANDLE ]; self::assertSame( plugins_url( 'tests/Fixtures/built.css', DWS_TPL_FILE ), $style->src ); self::assertSame( 'fixture', $style->ver ); - self::assertSame( 'print', $style->args ); + self::assertSame( 'all', $style->args ); self::assertSame( 'replace', $style->extra['rtl'] ?? null ); } } From 1b5da3bbbf7ecb20bb273647e5b77714408fc21f Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 09:58:48 +0100 Subject: [PATCH 03/28] Write the option keys as literals Three of the option keys were built from Plugin::PREFIX, so includes/options.php read the composition root while it loaded and held only because its name sorts after metadata.php, while the other two keys and the plugins' own includes write literals. Every key is a literal now, which the fill-in renames like any other, and no include reads Plugin while it loads (X152). PartialDeployTest's peer-getters row hid src/Plugin.php to make that include fail; it now adds an include that throws, as the other partial-deploy rows do. Assisted-by: Claude Code:claude-opus-5-5 --- includes/options.php | 8 +++----- tests/Integration/PartialDeployTest.php | 6 +++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/includes/options.php b/includes/options.php index 782d7b8..35f9580 100644 --- a/includes/options.php +++ b/includes/options.php @@ -8,8 +8,6 @@ * @package DeepWebSolutions\PluginTemplate */ -use DeepWebSolutions\PluginTemplate\Plugin; - defined( 'ABSPATH' ) || exit; /** @@ -20,7 +18,7 @@ * * @var string */ -const DWS_TPL_OPTION_DELETE_DATA = Plugin::PREFIX . '_delete_data'; +const DWS_TPL_OPTION_DELETE_DATA = 'dws_tpl_delete_data'; /** * The option that holds the greeting. @@ -30,7 +28,7 @@ * * @var string */ -const DWS_TPL_OPTION_GREETING = Plugin::PREFIX . '_greeting'; +const DWS_TPL_OPTION_GREETING = 'dws_tpl_greeting'; /** * The option that holds 'yes' where the site greets returning customers on their account dashboard. @@ -50,7 +48,7 @@ * * @var string */ -const DWS_TPL_OPTION_GREETING_ORDERS = Plugin::PREFIX . '_greeting_orders'; +const DWS_TPL_OPTION_GREETING_ORDERS = 'dws_tpl_greeting_orders'; /** * The option in which the migration runner records the revision the plugin's data has reached. diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 7a84809..b02087b 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -97,12 +97,12 @@ public function test_a_build_error_is_logged_with_its_location_once_an_hour(): v } public function test_the_peer_getters_a_partial_deploy_defines_still_answer(): void { - $root = \dirname( DWS_TPL_FILE ) . '/src/Plugin.php'; // options.php reads it at include time, after metadata.php has loaded. - \rename( $root, "$root.hidden" ); + $include = \dirname( DWS_TPL_FILE ) . '/includes/metadata_zz-throws.php'; // Sorts right after metadata.php, so no later include loads. + \file_put_contents( $include, " Date: Wed, 11 Nov 2026 09:59:25 +0100 Subject: [PATCH 04/28] Stage the first release's entry as a fragment the fill-in keeps CHANGELOG.md carried a hand-written "1.0.0 - unreleased" block, while the fill-in deletes every fragment, so a plugin's first changelog:write released 1.1.0 above that block and left "unreleased" in the file, unlike the framework, whose first entry is a fragment. The block goes, changelog/initial-release holds the entry, the fill-in deletes every fragment but that one, and CONTRIBUTING names the first release's --use-version=1.0.0, without which changelogger refuses a changelog that holds no version yet (X259's A635). The README's fill-in list names the safeguard recipe's test, which the fill-in also deletes (A634). Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 3 ++- CHANGELOG.md | 5 ----- CONTRIBUTING.md | 8 +++++++- README.md | 4 ++-- changelog/initial-release | 4 ++++ 5 files changed, 15 insertions(+), 9 deletions(-) create mode 100644 changelog/initial-release diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index e3de119..76251a4 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -85,7 +85,8 @@ perl -pi -e 'BEGIN { ( $description = $ENV{DESCRIPTION} ) =~ s/(["\\])/\\$1/g } # 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 plugin's first release writes the initial entry the template stages. +find changelog -type f ! -name '.gitkeep' ! -name 'initial-release' -delete # The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails. composer update --lock --no-install --no-scripts --no-plugins --ignore-platform-req=php+ --no-interaction diff --git a/CHANGELOG.md b/CHANGELOG.md index d4faa6d..2759479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,8 +3,3 @@ All notable changes to this plugin are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`. - -## 1.0.0 - unreleased - -### Added -- Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92a7b49..62dd805 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,8 +14,14 @@ The command asks for the change's significance, its type and its entry, and writ A release writes the fragments into a new version block of `CHANGELOG.md`, whose version the fragments' significance sets: -```bash +```sh composer changelog:write ``` +The first release has no earlier version to count from, so it names its own: + +```sh +composer changelog:write -- --use-version=1.0.0 +``` + 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. It sets each `blocks/src/*/block.json`'s `version` too, which WordPress puts in the block's stylesheet URL and the Unit suite holds to the header. Pushing the tag `v` on that commit starts the release. diff --git a/README.md b/README.md index 949a2d7..8e8854c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Create the plugin's repository from this template with GitHub's **Use this templ The workflow: - 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 any changelog fragments, and refreshes the Composer lock's content hash; +- deletes the disabled example include, the safeguard recipe's test and every changelog fragment but the initial entry, 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 @@ -66,7 +66,7 @@ Update the dependencies within their constraints, and `@wordpress/*` to npm's la 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. +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 but `changelog/initial-release`, the plugin's first entry, which the fill-in keeps while it deletes any other. `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/changelog/initial-release b/changelog/initial-release new file mode 100644 index 0000000..8685489 --- /dev/null +++ b/changelog/initial-release @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Initial release. From e4f322b59532d37817dfb538945295817fd90c83 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:00:19 +0100 Subject: [PATCH 05/28] Let the safeguards call WooCommerce where the plugin requires it Convention 7 and the safeguard recipe let a safeguard call only WordPress and its own file, yet a WooCommerce plugin's safeguard, such as LPM's gateway lock, must call WooCommerce, and its analysis profile had no WooCommerce stubs to read. A safeguard now calls WordPress, its file, and WooCommerce where the plugin requires it: what its hook hands it or APIs WooCommerce has had since 3.0, its CRUD release, guarding anything newer. phpstan.safeguards.neon scans WooCommerce's stubs, as LPM's does, and review checks the WooCommerce floor, since no tool reads WooCommerce's @since (X164). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- docs/recipes.md | 4 ++-- phpstan.safeguards.neon | 3 +++ 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 91337d1..ca53670 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 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`). 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. +7. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file, and WooCommerce where the plugin requires it, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, and only what their hook hands them or WooCommerce APIs present since 3.0, its CRUD release, guarding anything newer with `function_exists()` or `method_exists()`. `phpstan.safeguards.neon` checks the WordPress half with WooCommerce's stubs loaded; review checks the WooCommerce half, which no tool reads. 8. **Migrations** are append-only: a step converges from any partial state, verifies its writes and throws when one fails. 9. **WordPress floor.** The plugin calls only APIs its `Requires at least` has, PHP's and JavaScript's alike: PHPStan's wp-compat reads the floor from that header, and CI runs the end-to-end suite on the floor's WordPress, which supplies the `@wordpress/*` scripts at runtime. diff --git a/docs/recipes.md b/docs/recipes.md index 6961739..17696d7 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -17,9 +17,9 @@ These are the changes most plugins built from this template make, each as the fi ## A safeguard -A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the floor gate and the autoloader, so it calls only WordPress and what that file defines. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it. +A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the floor gate and the autoloader, so it calls only WordPress, what that file defines, and WooCommerce where the plugin requires it. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it. -Replace `includes/safeguards.php` with the helper and the safeguard. The file runs on any runtime that parses the plugin, so it must 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: +Replace `includes/safeguards.php` with the helper and the safeguard. The file runs on any runtime that parses the plugin, so it must parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, and only what its hook hands it or WooCommerce APIs present since 3.0, its CRUD release, guarding anything newer with `function_exists()` or `method_exists()`; `phpstan.safeguards.neon` checks the WordPress half: ```php Date: Wed, 11 Nov 2026 10:00:38 +0100 Subject: [PATCH 06/28] Tell a new plugin to switch on vulnerability reporting A repository made from a GitHub template copies none of its settings, so a new plugin starts with private vulnerability reporting and Dependabot alerts off, while its SECURITY.md routes reports through the first and development-tool advisories to the second. "Starting a plugin" now ends with turning both on (X134). The port input's note says the tests and multisite sites take the next two ports, as the fill-in assigns them (X132), and its examples move from 8821, the plugins monorepo's multisite lane, to 8831, the next free set, in the README, the fill-in's input and Quality's rehearsal (X273's A697). Assisted-by: Claude Code:claude-opus-5-5 --- .github/workflows/fill-in-scaffold.yml | 2 +- .github/workflows/quality.yml | 2 +- README.md | 4 +++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/fill-in-scaffold.yml b/.github/workflows/fill-in-scaffold.yml index c1750d6..b1d81a1 100644 --- a/.github/workflows/fill-in-scaffold.yml +++ b/.github/workflows/fill-in-scaffold.yml @@ -24,7 +24,7 @@ on: required: true type: string wp-env-port: - description: 'Development wp-env port, such as "8821"; the tests and multisite environments take the next two' + description: 'Development wp-env port, such as "8831"; the tests and multisite environments take the next two' required: true type: string diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 39b465c..81af646 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -89,7 +89,7 @@ jobs: NS: AcmeOrders # The rehearsal fills in the longest prefix the script accepts, so its PHPCS run fails once the cap stops passing. PREFIX: dws_acmeextraordinarysales - PORT: '8821' + PORT: '8831' REPO: acme/acme-orders run: .github/scripts/fill-in-scaffold.sh - name: Install and scope the filled-in plugin diff --git a/README.md b/README.md index 8e8854c..ca0a3d7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Create the plugin's repository from this template with GitHub's **Use this templ | `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 | +| `wp-env-port` | `8831` | the development site's port; the tests and multisite sites take the next two | The workflow: - 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; @@ -23,6 +23,8 @@ The workflow: - deletes the disabled example include, the safeguard recipe's test and every changelog fragment but the initial entry, 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. +Then turn on private vulnerability reporting and Dependabot alerts in the new repository's settings: its `SECURITY.md` sends reports through the first and development-tool advisories to the second, and a repository made from a template copies none of the template's settings. + ## Naming - A WooCommerce plugin's display name reads `X for WooCommerce`, as WooCommerce's trademark guidelines ask, and its slug ends in `-for-woocommerce`. From 51ae864caf198976dd1feacf9070736163995529 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:05:06 +0100 Subject: [PATCH 07/28] Share the tests' request and option harnesses Five Integration classes each copied the same snapshot of their options before a row and the same restore after it. RunsRequestsTrait took only a URL, so a request without the plugin, which the ports need for an uninstall, had to shell out by hand, and it kept a docblock no other test carries. The temp-file prefixes and the second site's path read dws-tpl, which the fill-in never renames. RestoresOptionsTrait now restores each class's OPTIONS, request() takes WP-CLI flags as LPM's does, and the prefixes derive from Plugin::PREFIX (X243's A515, A524 and A614, X244's A528). Assisted-by: Claude Code:claude-opus-5-5 --- .../Greeting/AccountDashboardGreetingTest.php | 13 +++------- .../Greeting/GreetingFeatureTest.php | 16 ++++--------- tests/Integration/MigrationsTest.php | 20 ++++------------ tests/Integration/MultisiteTest.php | 5 ++-- tests/Integration/OptionsTest.php | 20 ++++------------ tests/Integration/PartialDeployTest.php | 2 +- tests/Integration/SettingsTest.php | 23 +++++------------- tests/Integration/UninstallTest.php | 2 +- tests/Support/RestoresOptionsTrait.php | 24 +++++++++++++++++++ tests/Support/RunsRequestsTrait.php | 8 +++---- 10 files changed, 55 insertions(+), 78 deletions(-) create mode 100644 tests/Support/RestoresOptionsTrait.php diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index af1f38e..bef663c 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -10,9 +11,9 @@ #[CoversNothing] final class AccountDashboardGreetingTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + use RestoresOptionsTrait; - protected array $stored = array(); + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); protected int $customer = 0; @@ -34,10 +35,6 @@ public static function unpaid_statuses(): array { #[\Override] protected function setUp(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - $customer = wp_insert_user( array( 'user_login' => 'dws_tpl_customer_' . \wp_generate_password( 8, false ), @@ -52,10 +49,6 @@ protected function setUp(): void { #[After] protected function clean_up(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - foreach ( $this->orders as $order ) { $order->delete( true ); } diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 424e6c0..950518f 100644 --- a/tests/Integration/Greeting/GreetingFeatureTest.php +++ b/tests/Integration/Greeting/GreetingFeatureTest.php @@ -4,7 +4,7 @@ use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; use DeepWebSolutions\PluginTemplate\Plugin; -use PHPUnit\Framework\Attributes\After; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -12,7 +12,9 @@ #[CoversNothing] final class GreetingFeatureTest extends TestCase { - protected mixed $stored = false; + use RestoresOptionsTrait; + + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED ); public static function stored_toggles(): array { return array( @@ -22,16 +24,6 @@ public static function stored_toggles(): array { ); } - #[\Override] - protected function setUp(): void { - $this->stored = get_option( DWS_TPL_OPTION_GREETING_ENABLED ); - } - - #[After] - protected function restore_row(): void { - false === $this->stored ? delete_option( DWS_TPL_OPTION_GREETING_ENABLED ) : update_option( DWS_TPL_OPTION_GREETING_ENABLED, $this->stored ); - } - #[DataProvider( 'stored_toggles' )] public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?string $stored, bool $expected ): void { \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING_ENABLED ) : update_option( DWS_TPL_OPTION_GREETING_ENABLED, $stored ); diff --git a/tests/Integration/MigrationsTest.php b/tests/Integration/MigrationsTest.php index 384cd32..11742c5 100644 --- a/tests/Integration/MigrationsTest.php +++ b/tests/Integration/MigrationsTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migration\MigrationRunner; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,23 +11,12 @@ #[CoversNothing] final class MigrationsTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected array $stored = array(); - - #[\Override] - protected function setUp(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - + protected function restore_the_revision(): void { update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) ); } @@ -34,7 +24,7 @@ protected function restore_rows(): void { #[RunInSeparateProcess] public function test_the_runner_brings_a_new_site_to_revision_one_with_the_untranslated_defaults_autoloaded(): void { global $wpdb; - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } update_option( DWS_TPL_OPTION_REVISION, 0 ); diff --git a/tests/Integration/MultisiteTest.php b/tests/Integration/MultisiteTest.php index 4a32311..cee4d34 100644 --- a/tests/Integration/MultisiteTest.php +++ b/tests/Integration/MultisiteTest.php @@ -2,6 +2,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\Group; @@ -16,13 +17,13 @@ public function test_the_plugin_boots_on_another_site_of_the_network_and_stores_ $site_id = wp_insert_site( array( 'domain' => get_network()?->domain, - 'path' => get_network()?->path . 'dws-tpl-second/', + 'path' => get_network()?->path . \str_replace( '_', '-', Plugin::PREFIX ) . '-second/', ) ); self::assertIsInt( $site_id ); try { - $request = self::request( 'echo did_action( "dws_tpl/boot/succeeded" );', get_home_url( $site_id, '/' ) ); + $request = self::request( 'echo did_action( "dws_tpl/boot/succeeded" );', '--url=' . get_home_url( $site_id, '/' ) ); self::assertSame( '1', $request['last'], $request['output'] ); self::assertSame( 'no', get_blog_option( $site_id, DWS_TPL_OPTION_DELETE_DATA ) ); diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 28ea271..94b21b0 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -3,19 +3,19 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class OptionsTest extends TestCase { - protected const string KEY = Plugin::PREFIX . '_options_test'; + use RestoresOptionsTrait; - protected const array RESTORED = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const string KEY = Plugin::PREFIX . '_options_test'; - protected array $stored = array(); + protected const array OPTIONS = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); public static function stored_bools(): array { return array( @@ -63,19 +63,9 @@ public static function submitted_order_counts(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::RESTORED as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { + protected function delete_the_test_option(): void { delete_option( self::KEY ); - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } } #[DataProvider( 'stored_bools' )] diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index b02087b..7cd465f 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -125,7 +125,7 @@ public function test_a_deactivation_whose_composition_root_cannot_be_built_is_re #[RunInSeparateProcess] public function test_an_uninstall_whose_engine_is_missing_keeps_the_data_and_lets_the_web_delete_the_files(): void { update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); - $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); + $log = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-uninstall-' ); \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. \spl_autoload_register( static function ( string $class_name ): void { diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index e7739c3..fb4d1a5 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -4,19 +4,19 @@ use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\WooCommerce\Setting\SettingsTab; +use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; -use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversNothing] final class SettingsTest extends TestCase { - protected const array KEYS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); + use RestoresOptionsTrait; - protected static SettingsTab $tab; + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS, DWS_TPL_OPTION_DELETE_DATA ); - protected array $stored = array(); + protected static SettingsTab $tab; #[\Override] public static function setUpBeforeClass(): void { @@ -60,24 +60,13 @@ public static function whole_order_counts(): array { ); } - #[Before] - protected function store_rows(): void { - foreach ( self::KEYS as $key ) { - $this->stored[ $key ] = get_option( $key ); - } - } - #[After] - protected function restore_rows(): void { - foreach ( $this->stored as $key => $value ) { - false === $value ? delete_option( $key ) : update_option( $key, $value ); - } - + protected function forget_the_rejections(): void { ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->setValue( null, array() ); } public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defaults(): void { - foreach ( self::KEYS as $key ) { + foreach ( self::OPTIONS as $key ) { delete_option( $key ); } diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index d00be34..ffff02c 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -22,7 +22,7 @@ public function test_an_uninstall_checks_the_floors_of_its_own_main_file(): void \define( 'WP_UNINSTALL_PLUGIN', 'another-plugin/another-plugin.php' ); // A bulk uninstall keeps the first plugin's value for every later one. $source = (string) \file_get_contents( DWS_TPL_FILE ); \file_put_contents( DWS_TPL_FILE, (string) \preg_replace( '/^(\s*\*\s*Requires PHP:\s*).*$/m', '${1}99.0', $source ) ); - $log = (string) \tempnam( \sys_get_temp_dir(), 'dws-tpl-uninstall-' ); + $log = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-uninstall-' ); \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. try { diff --git a/tests/Support/RestoresOptionsTrait.php b/tests/Support/RestoresOptionsTrait.php new file mode 100644 index 0000000..5410975 --- /dev/null +++ b/tests/Support/RestoresOptionsTrait.php @@ -0,0 +1,24 @@ +stored_options[ $key ] = get_option( $key ); + } + } + + #[After] + protected function restore_options(): void { + foreach ( $this->stored_options as $key => $value ) { + false === $value ? delete_option( $key ) : update_option( $key, $value ); + } + } +} diff --git a/tests/Support/RunsRequestsTrait.php b/tests/Support/RunsRequestsTrait.php index 29ad5b1..5f7fd84 100644 --- a/tests/Support/RunsRequestsTrait.php +++ b/tests/Support/RunsRequestsTrait.php @@ -3,11 +3,9 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Support; trait RunsRequestsTrait { - /** - * @return array{status: int, output: string, last: string} - */ - protected static function request( string $php, string $url = '' ): array { - \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . ( '' === $url ? '' : ' --url=' . \escapeshellarg( $url ) ) . ' eval ' . \escapeshellarg( $php ) . ' 2>&1', $output, $status ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec -- Each row needs a fresh request that loads the plugin. + protected static function request( string $php, string ...$flags ): array { + $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); + \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&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( From 1df6a5da01f23dac5d14df25fee2c386f97e82c6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:05:06 +0100 Subject: [PATCH 08/28] Stop the Integration suite on what an aborted run left The partial-deploy and boot rows break the plugin on disk, adding a throwing include or hiding a built file, and repair it in a finally block, which a killed run never reaches. The next run then tested a broken plugin and failed far from the cause. The Integration bootstrap now names any such leftover and exits before it loads WordPress (X245's A537). Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/bootstrap.php | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/Integration/bootstrap.php b/tests/Integration/bootstrap.php index 9b272c9..4f1ce55 100644 --- a/tests/Integration/bootstrap.php +++ b/tests/Integration/bootstrap.php @@ -1,5 +1,21 @@ Date: Wed, 11 Nov 2026 10:05:43 +0100 Subject: [PATCH 09/28] Run the safeguard recipe's own test SafeguardRecipeTest loaded the recipe's safeguard but checked it with a hand copy of the recipe's SafeguardsTest, so the two could drift and the recipe's test itself never ran. It also wrote the recipe to a fixed temp file it never removed, and left the recipe's map_meta_cap filter on for every later row. The row now loads both blocks from docs/recipes.md and runs each of SafeguardsTest's rows; each block goes through its own temp file, deleted once required, and the filter goes after the class (X243's A524, X245's A539). A recipe that relaxed on any falsy value fails it. Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/SafeguardRecipeTest.php | 73 +++++++++-------------- 1 file changed, 27 insertions(+), 46 deletions(-) diff --git a/tests/Integration/SafeguardRecipeTest.php b/tests/Integration/SafeguardRecipeTest.php index 07c42c0..a05893c 100644 --- a/tests/Integration/SafeguardRecipeTest.php +++ b/tests/Integration/SafeguardRecipeTest.php @@ -2,6 +2,8 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; +use DeepWebSolutions\PluginTemplate\Plugin; +use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\BeforeClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -9,58 +11,37 @@ #[CoversNothing] final class SafeguardRecipeTest extends TestCase { - protected const string CAPABILITY = 'dws_tpl_export_data'; - - protected const string FILTER = 'dws_tpl/data_export/should_restrict'; - #[BeforeClass] public static function load_the_recipe(): void { - $recipes = \dirname( __DIR__, 2 ) . '/docs/recipes.md'; - self::assertFileExists( $recipes ); - \preg_match( '/```php\n(<\?php declare\( strict_types=1 \);\n(?:(?!```).)*?function dws_tpl_is_relaxed\((?:(?!```).)*)```/s', (string) \file_get_contents( $recipes ), $block ); - self::assertArrayHasKey( 1, $block, 'docs/recipes.md holds no includes/safeguards.php block defining dws_tpl_is_relaxed().' ); - - $file = \sys_get_temp_dir() . '/dws-tpl-safeguard-recipe.php'; - \file_put_contents( $file, $block[1] ); - require_once $file; - } - - public static function relaxations(): array { - return array( - 'none' => array( array(), false ), - 'null' => array( array( 'relaxation' => null ), false ), - 'zero' => array( array( 'relaxation' => 0 ), false ), - 'empty-string' => array( array( 'relaxation' => '' ), false ), - 'false' => array( array( 'relaxation' => false ), true ), - ); - } - - #[DataProvider( 'relaxations' )] - public function test_only_an_explicit_false_relaxes_the_safeguard( array $spec, bool $relaxed ): void { - $relaxation = static fn (): mixed => $spec['relaxation']; - if ( \array_key_exists( 'relaxation', $spec ) ) { - add_filter( self::FILTER, $relaxation ); - } - - try { - self::assertSame( $relaxed ? array( 'manage_options' ) : array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); - } finally { - remove_filter( self::FILTER, $relaxation ); + $recipes = (string) \file_get_contents( \dirname( __DIR__, 2 ) . '/docs/recipes.md' ); + foreach ( array( 'function dws_tpl_is_relaxed(', 'final class SafeguardsTest ' ) as $marker ) { + \preg_match( '/```php\n(<\?php declare\( strict_types=1 \);\n(?:(?!```).)*?' . \preg_quote( $marker, '/' ) . '(?:(?!```).)*)```/s', $recipes, $block ); + self::assertArrayHasKey( 1, $block, "docs/recipes.md holds no PHP file defining $marker." ); + + $file = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-safeguard-recipe-' ); + \file_put_contents( $file, $block[1] ); + require_once $file; + \unlink( $file ); } } - public function test_an_explicit_false_does_not_relax_the_safeguard_before_the_plugin_booted(): void { - global $wp_actions; - self::assertGreaterThan( 0, did_action( 'dws_tpl/boot/succeeded' ) ); - $fired = $wp_actions['dws_tpl/boot/succeeded']; - unset( $wp_actions['dws_tpl/boot/succeeded'] ); - add_filter( self::FILTER, '__return_false' ); + #[AfterClass] + public static function remove_the_recipes_safeguard(): void { + remove_filter( 'map_meta_cap', 'dws_tpl_map_export_capability' ); + } - try { - self::assertSame( array( 'do_not_allow' ), map_meta_cap( self::CAPABILITY, 0 ) ); - } finally { - remove_filter( self::FILTER, '__return_false' ); - $wp_actions['dws_tpl/boot/succeeded'] = $fired; + // The recipe's test class is loaded from its block, so PHPUnit never discovers it and this row runs each of its rows. + public function test_the_recipes_safeguard_passes_the_recipes_test(): void { + foreach ( new \ReflectionClass( SafeguardsTest::class )->getMethods( \ReflectionMethod::IS_PUBLIC ) as $test ) { + if ( ! \str_starts_with( $test->name, 'test_' ) ) { + continue; + } + + $provider = $test->getAttributes( DataProvider::class )[0] ?? null; + $rows = \is_null( $provider ) ? array( $test->name => array() ) : SafeguardsTest::{$provider->newInstance()->methodName()}(); + foreach ( $rows as $name => $row ) { + $test->invoke( new SafeguardsTest( (string) $name ), ...$row ); + } } } } From 83f926f284b27b89827103f9a5e76a1dc178b004 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:06:32 +0100 Subject: [PATCH 10/28] Uninstall in a request that never loaded the plugin WordPress uninstalls a plugin in a request that never included it, while every uninstall row ran with the plugin booted, so uninstall.php could read anything the plugin defines and still pass; LPM already has the row the template lacked. A row now uninstalls through WP-CLI with --skip-plugins and finds the consent option gone, and it alone fails when uninstall.php reads DWS_TPL_FILE (the delegated A467). PartialDeployTest's missing-engine row wrote 'no' back, whatever the site had stored, and only after its assertions passed; it now restores the consent it read, in a finally block (X259's A633). Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/PartialDeployTest.php | 15 +++++++++------ tests/Integration/UninstallTest.php | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/tests/Integration/PartialDeployTest.php b/tests/Integration/PartialDeployTest.php index 7cd465f..7a22840 100644 --- a/tests/Integration/PartialDeployTest.php +++ b/tests/Integration/PartialDeployTest.php @@ -124,6 +124,7 @@ public function test_a_deactivation_whose_composition_root_cannot_be_built_is_re #[RunInSeparateProcess] public function test_an_uninstall_whose_engine_is_missing_keeps_the_data_and_lets_the_web_delete_the_files(): void { + $consent = get_option( DWS_TPL_OPTION_DELETE_DATA ); update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); $log = (string) \tempnam( \sys_get_temp_dir(), \str_replace( '_', '-', Plugin::PREFIX ) . '-uninstall-' ); \ini_set( 'error_log', $log ); // phpcs:ignore WordPress.PHP.IniSet.Risky -- The child process's stderr would fail the test. @@ -137,11 +138,13 @@ static function ( string $class_name ): void { true ); - self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) ); - self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); - self::assertStringContainsString( 'kept its data on uninstall', (string) \file_get_contents( $log ) ); - - \unlink( $log ); - update_option( DWS_TPL_OPTION_DELETE_DATA, 'no' ); + try { + self::assertTrue( uninstall_plugin( plugin_basename( DWS_TPL_FILE ) ) ); + self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); + self::assertStringContainsString( 'kept its data on uninstall', (string) \file_get_contents( $log ) ); + } finally { + \unlink( $log ); + false === $consent ? delete_option( DWS_TPL_OPTION_DELETE_DATA ) : update_option( DWS_TPL_OPTION_DELETE_DATA, $consent ); + } } } diff --git a/tests/Integration/UninstallTest.php b/tests/Integration/UninstallTest.php index ffff02c..5288540 100644 --- a/tests/Integration/UninstallTest.php +++ b/tests/Integration/UninstallTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\RunsRequestsTrait; use PHPUnit\Framework\Attributes\AfterClass; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\RunInSeparateProcess; @@ -10,6 +11,8 @@ #[CoversNothing] final class UninstallTest extends TestCase { + use RunsRequestsTrait; + protected const int USER = 1; #[AfterClass] @@ -73,6 +76,22 @@ public function test_a_consenting_uninstall_removes_a_row_of_every_kind_the_fram self::assertSame( 'no', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); } + #[RunInSeparateProcess] + public function test_an_uninstall_in_a_request_that_never_loaded_the_plugin_removes_its_data(): void { + update_option( DWS_TPL_OPTION_DELETE_DATA, 'yes' ); + + try { + // WordPress uninstalls a plugin in a request that never included it, as WP-CLI's --skip-plugins does here. + $request = self::request( 'require_once ABSPATH . "wp-admin/includes/plugin.php"; echo wp_json_encode( array( uninstall_plugin( "' . plugin_basename( DWS_TPL_FILE ) . '" ), get_option( "' . DWS_TPL_OPTION_DELETE_DATA . '" ) ) );', '--skip-plugins=' . Plugin::SLUG ); + + self::assertSame( '[true,false]', $request['last'], $request['output'] ); + } finally { + wp_cache_flush(); + // The other tests share the database, so the runner stores again what a new site starts with. + dws_tpl_plugin()->migrations?->run(); + } + } + #[RunInSeparateProcess] public function test_a_web_uninstall_whose_engine_fails_ends_with_its_message_and_keeps_the_consent(): void { global $wpdb; From eb2aaad2935da49792df6ff793c9eaa5153520c5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:07:27 +0100 Subject: [PATCH 11/28] Count the greeting's orders on every order storage LO, IC and LPM each copied an OrderStorage harness, under two folders and without the Trait suffix, with a caching parameter none passes, while the template, which they follow, had none. OrderStorageTrait now lives in the template's tests/Support, with the order tables' data cache off and no parameter for it, and one row uses it: the greeting counts one paid order and skips a failed one on posts and on the order tables, with and without sync, which a posts-only query fails (X243's A520, A522 and A523). Assisted-by: Claude Code:claude-opus-5-5 --- .../Greeting/AccountDashboardGreetingTest.php | 24 +++++--- tests/Support/OrderStorageTrait.php | 59 +++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 tests/Support/OrderStorageTrait.php diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index bef663c..1856c77 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; use PHPUnit\Framework\Attributes\CoversNothing; @@ -11,14 +12,13 @@ #[CoversNothing] final class AccountDashboardGreetingTest extends TestCase { + use OrderStorageTrait; use RestoresOptionsTrait; protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); protected int $customer = 0; - protected array $orders = array(); - #[\Override] public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; @@ -49,10 +49,6 @@ protected function setUp(): void { #[After] protected function clean_up(): void { - foreach ( $this->orders as $order ) { - $order->delete( true ); - } - wp_set_current_user( 0 ); wp_delete_user( $this->customer ); wp_dequeue_style( 'dws-plugin-template-greeting' ); @@ -142,6 +138,20 @@ public function test_a_customer_below_the_set_number_is_not_greeted(): void { self::assertSame( '', $this->print() ); } + #[DataProvider( 'storages' )] + public function test_paid_orders_count_towards_the_set_number_on_every_order_storage( bool $hpos, bool $sync ): void { + $this->use_storage( $hpos, $sync ); + update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); + $this->place_orders( 1 ); + $this->place_orders( 1, 'failed' ); + + self::assertSame( '', $this->print() ); + + $this->place_orders( 1, 'processing' ); + + self::assertNotSame( '', $this->print() ); + } + #[DataProvider( 'unpaid_statuses' )] public function test_orders_that_are_not_paid_do_not_count_towards_the_set_number( string $status ): void { update_option( DWS_TPL_OPTION_GREETING_ORDERS, '2' ); @@ -168,7 +178,7 @@ public function test_an_empty_greeting_prints_its_translated_default(): void { protected function place_orders( int $count, string $status = 'completed' ): void { for ( $placed = 0; $placed < $count; $placed++ ) { - $this->orders[] = wc_create_order( + $this->create_order( array( 'customer_id' => $this->customer, 'status' => $status, diff --git a/tests/Support/OrderStorageTrait.php b/tests/Support/OrderStorageTrait.php new file mode 100644 index 0000000..5c55577 --- /dev/null +++ b/tests/Support/OrderStorageTrait.php @@ -0,0 +1,59 @@ + array( false, false ), + 'posts-with-sync' => array( false, true ), + 'hpos' => array( true, false ), + 'hpos-with-sync' => array( true, true ), + ); + } + + protected function use_storage( bool $hpos, bool $sync ): void { + $this->storage_filters = array( + 'pre_option_woocommerce_custom_orders_table_enabled' => static fn (): string => $hpos ? 'yes' : 'no', + 'pre_option_woocommerce_custom_orders_table_data_sync_enabled' => static fn (): string => $sync ? 'yes' : 'no', + 'pre_option_woocommerce_hpos_datastore_caching_enabled' => static fn (): string => 'no', + ); + foreach ( $this->storage_filters as $hook => $filter ) { + add_filter( $hook, $filter ); + } + } + + protected function create_order( array $args = array() ): \WC_Order { + $order = wc_create_order( $args + array( 'status' => 'pending' ) ); + self::assertInstanceOf( \WC_Order::class, $order ); + $this->orders[] = $order->get_id(); + + return $order; + } + + // The orders go before the filters, because an order deletes from the storage it was loaded from. + #[After] + protected function delete_orders_and_restore_storage(): void { + foreach ( $this->orders as $id ) { + $order = wc_get_order( $id ); + if ( $order instanceof \WC_Order ) { + $order->delete( true ); + } + } + + $this->orders = array(); + + foreach ( $this->storage_filters as $hook => $filter ) { + remove_filter( $hook, $filter ); + } + + $this->storage_filters = array(); + } +} From d592be69abc322285be560ee0600cb133dc641ff Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:10:22 +0100 Subject: [PATCH 12/28] Make the template's rows observe what they claim Several rows passed whatever the code did: - the revision row read a revision any MigrationsTest row had already forced, so it held with the runner unhooked; - the enabled-greeting boot checked only that nothing was skipped, so a greeting cut from the tree passed; - the translated-default rows compared the reader with the same untranslated call, so dropping the __() passed; - the component walk was breadth-first, unlike the boot, and looped forever on a cycle. A request now finds the revision brought back from 0 on wp_loaded; the enabled boot greets a customer with a paid order on the account dashboard; a sentinel translation through the text-domain filter reaches the reader and the printed greeting; the walk goes depth-first and fails on a class it already visited. Each row fails on the mutant it names (X249's A558-A561, X243's A525). The boot row reads the greeting's gate rather than the tests site's stored state, the untouched-greeting rows fold into one provider and set the threshold they read, and three rows take the names the behavior they check gives them (X245's A538, X256's A611-A613). Assisted-by: Claude Code:claude-opus-5-5 --- .../Greeting/AccountDashboardGreetingTest.php | 36 ++++++++++------ .../Greeting/GreetingFeatureTest.php | 43 ++++++++++++++++--- tests/Integration/OptionsTest.php | 38 +++++++++------- tests/Integration/PluginBootTest.php | 18 +++++--- tests/Integration/SettingsTest.php | 2 +- tests/Unit/ComponentTreeTest.php | 7 ++- 6 files changed, 100 insertions(+), 44 deletions(-) diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index 1856c77..a59ef55 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -3,6 +3,7 @@ namespace DeepWebSolutions\PluginTemplate\Tests\Integration\Greeting; use DeepWebSolutions\PluginTemplate\Greeting\AccountDashboardGreeting; +use DeepWebSolutions\PluginTemplate\Plugin; use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\After; @@ -17,6 +18,8 @@ final class AccountDashboardGreetingTest extends TestCase { protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const string TRANSLATION = 'Translated greeting'; + protected int $customer = 0; #[\Override] @@ -24,6 +27,13 @@ public static function setUpBeforeClass(): void { require_once ABSPATH . 'wp-admin/includes/user.php'; } + public static function untouched_greetings(): array { + return array( + 'unsaved' => array( null ), + 'empty' => array( '' ), + ); + } + public static function unpaid_statuses(): array { return array( 'pending' => array( 'pending' ), @@ -37,7 +47,7 @@ public static function unpaid_statuses(): array { protected function setUp(): void { $customer = wp_insert_user( array( - 'user_login' => 'dws_tpl_customer_' . \wp_generate_password( 8, false ), + 'user_login' => Plugin::PREFIX . '_customer_' . wp_generate_password( 8, false ), 'user_pass' => wp_generate_password(), 'role' => 'customer', ) @@ -55,7 +65,7 @@ protected function clean_up(): void { wp_deregister_style( 'dws-plugin-template-greeting' ); } - public function test_the_greeting_prints_on_the_account_dashboard(): void { + public function test_the_greeting_hooks_the_account_dashboard_and_its_style(): void { $detail = new AccountDashboardGreeting(); $detail->register_hooks(); @@ -161,19 +171,19 @@ public function test_orders_that_are_not_paid_do_not_count_towards_the_set_numbe self::assertSame( '', $this->print() ); } - public function test_an_unsaved_greeting_prints_its_translated_default(): void { - delete_option( DWS_TPL_OPTION_GREETING ); - delete_option( DWS_TPL_OPTION_GREETING_ORDERS ); - $this->place_orders( 1 ); - - self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); - } - - public function test_an_empty_greeting_prints_its_translated_default(): void { - update_option( DWS_TPL_OPTION_GREETING, '' ); + #[DataProvider( 'untouched_greetings' )] + public function test_an_untouched_greeting_prints_its_translated_default( ?string $stored ): void { + \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING ) : update_option( DWS_TPL_OPTION_GREETING, $stored ); + update_option( DWS_TPL_OPTION_GREETING_ORDERS, '1' ); $this->place_orders( 1 ); + $translate = static fn (): string => self::TRANSLATION; + add_filter( 'gettext_' . Plugin::SLUG, $translate ); - self::assertStringContainsString( esc_html( dws_tpl_get_option_text( DWS_TPL_OPTION_GREETING ) ), $this->print() ); + try { + self::assertStringContainsString( self::TRANSLATION, $this->print() ); + } finally { + remove_filter( 'gettext_' . Plugin::SLUG, $translate ); + } } protected function place_orders( int $count, string $status = 'completed' ): void { diff --git a/tests/Integration/Greeting/GreetingFeatureTest.php b/tests/Integration/Greeting/GreetingFeatureTest.php index 950518f..0bbabec 100644 --- a/tests/Integration/Greeting/GreetingFeatureTest.php +++ b/tests/Integration/Greeting/GreetingFeatureTest.php @@ -4,6 +4,7 @@ use DeepWebSolutions\PluginTemplate\Greeting\GreetingFeature; use DeepWebSolutions\PluginTemplate\Plugin; +use DeepWebSolutions\PluginTemplate\Tests\Support\OrderStorageTrait; use DeepWebSolutions\PluginTemplate\Tests\Support\RestoresOptionsTrait; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; @@ -12,9 +13,10 @@ #[CoversNothing] final class GreetingFeatureTest extends TestCase { + use OrderStorageTrait; use RestoresOptionsTrait; - protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED ); + protected const array OPTIONS = array( DWS_TPL_OPTION_GREETING_ENABLED, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); public static function stored_toggles(): array { return array( @@ -32,12 +34,41 @@ public function test_the_greeting_loads_only_where_a_site_switches_it_on( ?strin } #[RunInSeparateProcess] - public function test_with_the_greeting_switched_on_every_declared_component_boots(): void { + public function test_with_the_greeting_switched_on_the_boot_greets_a_customer_on_the_account_dashboard(): void { + require_once ABSPATH . 'wp-admin/includes/user.php'; + add_filter( 'pre_wp_mail', '__return_false' ); // A completed order emails its customer, and the mailer's stderr fails a row in a separate process. update_option( DWS_TPL_OPTION_GREETING_ENABLED, 'yes' ); - $plugin = new Plugin(); - $plugin->boot(); + update_option( DWS_TPL_OPTION_GREETING, 'Welcome back.' ); + update_option( DWS_TPL_OPTION_GREETING_ORDERS, '1' ); + $customer = wp_insert_user( + array( + 'user_login' => Plugin::PREFIX . '_customer_' . wp_generate_password( 8, false ), + 'user_pass' => wp_generate_password(), + 'role' => 'customer', + ) + ); + self::assertIsInt( $customer ); + + try { + $this->create_order( + array( + 'customer_id' => $customer, + 'status' => 'completed', + ) + ); + wp_set_current_user( $customer ); + $plugin = new Plugin(); + $plugin->boot(); + \ob_start(); + do_action( 'woocommerce_account_dashboard' ); + $dashboard = (string) \ob_get_clean(); - self::assertSame( 'booted', $plugin->boundary->status ); - self::assertSame( array(), $plugin->kernel?->skipped ); + self::assertSame( 'booted', $plugin->boundary->status ); + self::assertSame( array(), $plugin->kernel?->skipped ); + self::assertStringContainsString( 'Welcome back.', $dashboard ); + } finally { + wp_set_current_user( 0 ); + wp_delete_user( $customer ); + } } } diff --git a/tests/Integration/OptionsTest.php b/tests/Integration/OptionsTest.php index 94b21b0..85eb055 100644 --- a/tests/Integration/OptionsTest.php +++ b/tests/Integration/OptionsTest.php @@ -17,6 +17,8 @@ final class OptionsTest extends TestCase { protected const array OPTIONS = array( DWS_TPL_OPTION_DELETE_DATA, DWS_TPL_OPTION_GREETING, DWS_TPL_OPTION_GREETING_ORDERS ); + protected const string TRANSLATION = 'Translated greeting'; + public static function stored_bools(): array { return array( 'yes' => array( 'yes', true ), @@ -50,6 +52,14 @@ public static function stored_strings(): array { ); } + public static function untouched_strings(): array { + return array( + 'unsaved' => array( null ), + 'empty' => array( '' ), + 'not-a-string' => array( array( 'Hello' ) ), + ); + } + public static function submitted_order_counts(): array { return array( 'one' => array( '1', '1' ), @@ -101,27 +111,25 @@ public function test_a_string_reads_a_stored_string_or_the_empty_string( mixed $ self::assertSame( $expected, dws_tpl_get_string_option( self::KEY ) ); } - public function test_a_string_without_a_stored_value_reads_its_translated_default(): void { - delete_option( DWS_TPL_OPTION_GREETING ); - - 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 ) ); + #[DataProvider( 'untouched_strings' )] + public function test_a_string_without_a_usable_stored_value_reads_its_translated_default( mixed $stored ): void { + \is_null( $stored ) ? delete_option( DWS_TPL_OPTION_GREETING ) : update_option( DWS_TPL_OPTION_GREETING, $stored ); + $translate = static fn (): string => self::TRANSLATION; + add_filter( 'gettext_' . Plugin::SLUG, $translate ); + + try { + self::assertSame( self::TRANSLATION, dws_tpl_get_string_option( DWS_TPL_OPTION_GREETING ) ); + } finally { + remove_filter( 'gettext_' . Plugin::SLUG, $translate ); + } + } + public function test_a_stored_string_reads_over_its_translated_default(): void { update_option( DWS_TPL_OPTION_GREETING, 'Hello' ); 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_GREETING_ENABLED ) ); } diff --git a/tests/Integration/PluginBootTest.php b/tests/Integration/PluginBootTest.php index 36283b8..8dad5c6 100644 --- a/tests/Integration/PluginBootTest.php +++ b/tests/Integration/PluginBootTest.php @@ -15,11 +15,11 @@ final class PluginBootTest extends TestCase { use RunsRequestsTrait; - public function test_the_plugin_boots_with_the_greeting_gated_off_until_a_site_switches_it_on(): void { + public function test_the_plugin_boots_and_skips_the_greeting_while_its_gate_is_off(): void { $plugin = dws_tpl_plugin(); self::assertSame( 'booted', $plugin->boundary->status ); - self::assertSame( array( GreetingFeature::class ), $plugin->kernel?->skipped ); + self::assertSame( GreetingFeature::should_load() ? array() : array( GreetingFeature::class ), $plugin->kernel?->skipped ); } public function test_the_entry_loads_every_include_but_those_whose_names_start_with_an_underscore(): void { @@ -69,9 +69,17 @@ public function test_the_debug_constant_logs_every_level(): void { 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( DWS_TPL_OPTION_REVISION ) ); + public function test_a_request_brings_the_stored_data_to_the_revision_of_the_code(): void { + update_option( DWS_TPL_OPTION_REVISION, 0 ); + try { + $request = self::request( 'echo ' . MigrationRunner::class . '::get_revision( "' . DWS_TPL_OPTION_REVISION . '" );' ); + } finally { + wp_cache_flush(); // The request wrote the revision behind this process's cache. + update_option( DWS_TPL_OPTION_REVISION, \count( dws_tpl_get_migration_steps() ) ); + } + + self::assertSame( 0, $request['status'], $request['output'] ); + self::assertSame( (string) \count( dws_tpl_get_migration_steps() ), $request['last'] ); } protected static function get_threshold( WooCommerceLogger $logger ): mixed { diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index fb4d1a5..6831ca1 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -98,7 +98,7 @@ public function test_a_save_that_leaves_the_message_untouched_stores_no_translat 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 { + public function test_the_uninstall_consent_round_trips_through_woocommerces_save(): void { $this->save( array( DWS_TPL_OPTION_DELETE_DATA => '1' ) ); self::assertSame( 'yes', get_option( DWS_TPL_OPTION_DELETE_DATA ) ); diff --git a/tests/Unit/ComponentTreeTest.php b/tests/Unit/ComponentTreeTest.php index 42ec07a..36cda50 100644 --- a/tests/Unit/ComponentTreeTest.php +++ b/tests/Unit/ComponentTreeTest.php @@ -13,13 +13,12 @@ public function test_no_component_appears_twice_in_the_tree_whatever_its_gates() $walked = array(); $pending = Plugin::COMPONENTS; while ( array() !== $pending ) { - $class = \array_shift( $pending ); + $class = \array_shift( $pending ); + self::assertNotContains( $class, $walked, "$class appears twice in the component tree." ); $walked[] = $class; if ( \is_a( $class, CompositeComponentInterface::class, true ) ) { - \array_push( $pending, ...$class::get_child_component_classes() ); + \array_unshift( $pending, ...$class::get_child_component_classes() ); } } - - self::assertSame( \array_values( \array_unique( $walked ) ), $walked ); } } From ce7a64a20d2b6e218680776fe890e8a4f8ca5938 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:11:53 +0100 Subject: [PATCH 13/28] Publish the example block's style in the end-to-end suite The editor spec inserted the example block but never chose its Outlined style or looked at the front end, so a dynamic render that dropped the block's wrapper classes, or a block.json without the style, passed it. The row now picks Outlined, publishes the post, finds the outlined notice on the front end, runs axe over it and deletes the post afterwards (X250's A569). The WP-CLI helper the without-WooCommerce spec inlined moves to tests/EndToEnd/cli.ts, which a port's specs import instead of copying it (X243's A521). Assisted-by: Claude Code:claude-opus-5-5 --- tests/EndToEnd/cli.ts | 13 ++++++ tests/EndToEnd/editor.spec.ts | 49 ++++++++++++++++++---- tests/EndToEnd/without-woocommerce.spec.ts | 26 ++---------- tests/README.md | 2 +- 4 files changed, 60 insertions(+), 30 deletions(-) create mode 100644 tests/EndToEnd/cli.ts diff --git a/tests/EndToEnd/cli.ts b/tests/EndToEnd/cli.ts new file mode 100644 index 0000000..14aabda --- /dev/null +++ b/tests/EndToEnd/cli.ts @@ -0,0 +1,13 @@ +/// +import { execFileSync } from 'node:child_process'; + +// No screen shows some of what the specs set up or check, so they run WP-CLI on the tests site and read its last line. +export const cli = ( ...args: string[] ): string => + execFileSync( + 'node_modules/.bin/wp-env', + [ '--config', '.wp-env.tests.json', 'run', 'cli', 'wp', ...args ], + { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } + ) + .trim() + .split( '\n' ) + .pop() ?? ''; diff --git a/tests/EndToEnd/editor.spec.ts b/tests/EndToEnd/editor.spec.ts index f47d9a8..bb831c0 100644 --- a/tests/EndToEnd/editor.spec.ts +++ b/tests/EndToEnd/editor.spec.ts @@ -1,7 +1,24 @@ +import AxeBuilder from '@axe-core/playwright'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +const NAME = 'dws-plugin-template/example-notice'; +const TEXT = 'An example notice from DWS Plugin Template.'; + test.describe( 'Block editor', () => { - test( 'inserts the example notice from the command palette', async ( { + let post: number | null = null; + + test.afterEach( async ( { requestUtils } ) => { + if ( null !== post ) { + await requestUtils.rest( { + method: 'DELETE', + path: `/wp/v2/posts/${ post }`, + params: { force: true }, + } ); + post = null; + } + } ); + + test( 'inserts the example notice from the command palette and publishes its Outlined style', async ( { admin, editor, page, @@ -16,15 +33,33 @@ test.describe( 'Block editor', () => { .fill( 'Insert an example notice' ); await page.keyboard.press( 'Enter' ); + await expect + .poll( editor.getBlocks ) + .toMatchObject( [ { name: NAME } ] ); + await expect( editor.canvas.getByText( TEXT ) ).toBeVisible(); + + await editor.openDocumentSettingsSidebar(); + await page + .getByRole( 'region', { name: 'Editor settings' } ) + .getByRole( 'button', { name: 'Outlined' } ) + .click(); await expect .poll( editor.getBlocks ) .toMatchObject( [ - { name: 'dws-plugin-template/example-notice' }, + { name: NAME, attributes: { className: 'is-style-outlined' } }, ] ); - await expect( - editor.canvas.getByText( - 'An example notice from DWS Plugin Template.' - ) - ).toBeVisible(); + + post = await editor.publishPost(); + expect( post ).not.toBeNull(); + await page.goto( `/?p=${ post }` ); + + const notice = page.locator( + '.wp-block-dws-plugin-template-example-notice.is-style-outlined' + ); + await expect( notice ).toHaveText( TEXT ); + const results = await new AxeBuilder( { page } ) + .include( '.wp-block-dws-plugin-template-example-notice' ) + .analyze(); + expect( results.violations ).toEqual( [] ); } ); } ); diff --git a/tests/EndToEnd/without-woocommerce.spec.ts b/tests/EndToEnd/without-woocommerce.spec.ts index b557705..63e7fb6 100644 --- a/tests/EndToEnd/without-woocommerce.spec.ts +++ b/tests/EndToEnd/without-woocommerce.spec.ts @@ -1,30 +1,10 @@ -/// -import { execFileSync } from 'node:child_process'; import { expect, test } from '@wordpress/e2e-test-utils-playwright'; +import { cli } from './cli'; // WordPress core's own message for a plugin whose Requires Plugins header lists an inactive plugin. const DEPENDENCY = 'DWS Plugin Template requires 1 plugin to be installed and activated: WooCommerce.'; -// No page shows the boot's outcome, so the site's WP-CLI reads it. -const bootStatus = (): string => - execFileSync( - 'node_modules/.bin/wp-env', - [ - '--config', - '.wp-env.tests.json', - 'run', - 'cli', - 'wp', - 'eval', - 'echo dws_tpl_plugin()->boundary->status;', - ], - { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'ignore' ] } - ) - .trim() - .split( '\n' ) - .pop() ?? ''; - test.describe( 'Without WooCommerce', () => { test.beforeAll( async ( { requestUtils } ) => { await requestUtils.deactivatePlugin( 'woocommerce' ); @@ -37,7 +17,9 @@ test.describe( 'Without WooCommerce', () => { test( 'the plugin stays blocked, WordPress names the missing plugin and every screen still loads', async ( { page, } ) => { - expect( bootStatus() ).toBe( 'blocked' ); + expect( + cli( 'eval', 'echo dws_tpl_plugin()->boundary->status;' ) + ).toBe( 'blocked' ); const front = await page.goto( '/' ); expect( front?.status() ).toBe( 200 ); diff --git a/tests/README.md b/tests/README.md index 582e94d..7818345 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,7 +8,7 @@ This document describes the plugin's three test suites, the environments they ru | --- | --- | --- | --- | | 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, the example block inserted from the editor's command palette, and the plugin with WooCommerce deactivated | +| 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, the example block inserted from the editor's command palette and published in its Outlined style, and the plugin with WooCommerce deactivated | PHPUnit runs both PHP suites in random order, so each row sets up what it needs and restores what it changes; its cache lives in `tests/.cache/phpunit/`, and Playwright's output in `tests/.cache/artifacts/`. From 70cfa39e5acd88ddbcd2df331093c629a6ee00a5 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:12:12 +0100 Subject: [PATCH 14/28] Give the entry and the scoper config their file docblock tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style guide §1 has every procedural file's docblock hold a description, @since, @version and @package, and the entry and scoper.inc.php were the template's only such files without them, so every plugin made from the template inherited the gap. The entry's docblock now opens with what the file does and both carry @since and @version 1.0.0, the version the template seeds (X143, X162). Assisted-by: Claude Code:claude-opus-5-5 --- dws-plugin-template.php | 5 +++++ scoper.inc.php | 3 +++ 2 files changed, 8 insertions(+) diff --git a/dws-plugin-template.php b/dws-plugin-template.php index b325246..cec1b6f 100644 --- a/dws-plugin-template.php +++ b/dws-plugin-template.php @@ -1,5 +1,7 @@ Date: Wed, 11 Nov 2026 10:13:18 +0100 Subject: [PATCH 15/28] Name each docblock's role and give its reason a paragraph Several docblock summaries ran on: they listed what the code below does, packed their reason into the summary behind a semicolon or a "which", or addressed the template's user in files every plugin keeps. metadata.php also told a peer how to call its getters, which AGENTS.md convention 4 already says; the options readers said "only after init" where callers may read from init on; and the build-error @throws read "Thrown in". Each summary now names its role in one sentence, the one reason left moves to a second paragraph, and the listings go. The compatibility declaration says the plugin does not yet work with the order tables and that each feature flag is declared from evidence, and the @throws reads "Thrown when the site runs in a local or development environment." The safeguard recipe's file and the Without WooCommerce recipe follow (X202's A257-A261, X290's A745). Assisted-by: Claude Code:claude-opus-5-5 --- blocks/src/example-notice/render.php | 4 +++- docs/recipes.md | 6 ++++-- dws-plugin-template.php | 4 ++-- functions.php | 6 ++++-- includes/assets.php | 2 +- includes/footprint.php | 2 +- includes/metadata.php | 8 +++++--- includes/migrations.php | 4 +++- includes/options.php | 6 ++++-- includes/safeguards.php | 4 +++- src/Plugin.php | 2 +- src/Settings.php | 2 +- 12 files changed, 32 insertions(+), 18 deletions(-) diff --git a/blocks/src/example-notice/render.php b/blocks/src/example-notice/render.php index 96c92d5..2e4d5df 100644 --- a/blocks/src/example-notice/render.php +++ b/blocks/src/example-notice/render.php @@ -1,6 +1,8 @@ Date: Wed, 11 Nov 2026 10:13:44 +0100 Subject: [PATCH 16/28] Tag the plugin's API and state its contract Nothing in the template marked which of its functions a peer may rely on, and its AGENTS.md had no Contract section, so every global function and class read as supported. The three getters now carry @api in a tag group of their own after the description, and AGENTS.md's Contract section says the contract is the @api elements, the hooks the plugin fires and its option, meta and capability names and REST routes, with everything else internal (X150). The accessor stays untagged: it returns the composition root, whose members are internal, and add-ons read the plugin only through the getters (X362). The section names no major version, since every plugin made from the template starts at 1.0.0 (X360). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 4 ++++ includes/metadata.php | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index ca53670..a949a43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,6 +62,10 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 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. +## Contract + +The plugin's contract is the elements tagged `@api`, the hooks it fires, and its option, meta and capability names and REST routes; breaking it ships as the next major. Every other function, constant and class, `src/` included, is internal and changes in any release. + ## Style - Every PHP file opens with ` Date: Wed, 11 Nov 2026 10:14:12 +0100 Subject: [PATCH 17/28] Keep each return beside the statement before it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style guide §5 puts no blank line between a statement and the return after it, yet ten places in the template's code and tests, the option readers among them, and six recipe snippets kept one. The sweep closes each gap and changes no PHP token, which a comparison of every file's non-whitespace tokens with the previous commit shows (X212's A306). The asset fixture keeps the blank line after its opening tag, since it mirrors the file wp-scripts generates. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 6 ------ includes/assets.php | 1 - includes/metadata.php | 1 - includes/options.php | 2 -- src/Plugin.php | 1 - src/Settings.php | 1 - tests/Integration/Greeting/AccountDashboardGreetingTest.php | 1 - tests/Integration/MetadataTest.php | 1 - tests/Integration/SettingsTest.php | 1 - tests/Support/OrderStorageTrait.php | 1 - tests/Support/RunsRequestsTrait.php | 1 - 11 files changed, 17 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index 179dcdb..f2ab4d8 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -251,7 +251,6 @@ function dws_tpl_get_int_setting( string $field ): int { $default = DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; $stored = get_option( DWS_TPL_OPTION_SETTINGS ); $stored = is_array( $stored ) && array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; - return filter_var( $stored, FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); } @@ -621,7 +620,6 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti */ public function add_settings_link( array $links ): array { array_unshift( $links, sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); - return $links; } @@ -1026,7 +1024,6 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } ``` @@ -1199,7 +1196,6 @@ const DWS_TPL_SETTING_DEFAULTS = array( */ function dws_tpl_get_bool_setting( string $field ): bool { $default = dws_tpl_get_setting_default( $field ); - return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); } @@ -1215,7 +1211,6 @@ function dws_tpl_get_bool_setting( string $field ): bool { */ function dws_tpl_get_int_setting( string $field ): int { $default = dws_tpl_get_setting_default( $field ); - return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); } @@ -1253,7 +1248,6 @@ function dws_tpl_validate_limit( $submitted ) { */ function dws_tpl_get_setting( string $field, $fallback ) { $settings = get_option( DWS_TPL_OPTION_SETTINGS ); - return is_array( $settings ) && array_key_exists( $field, $settings ) ? $settings[ $field ] : $fallback; } diff --git a/includes/assets.php b/includes/assets.php index cf18204..705c965 100644 --- a/includes/assets.php +++ b/includes/assets.php @@ -29,7 +29,6 @@ function dws_tpl_get_asset( string $path ): array { * @var array{dependencies: list, version: string, type?: string, handle?: string} $asset */ $asset = require plugin_dir_path( DWS_TPL_FILE ) . dirname( $path ) . '/' . pathinfo( $path, PATHINFO_FILENAME ) . '.asset.php'; - return $asset; } diff --git a/includes/metadata.php b/includes/metadata.php index 544c579..366d764 100644 --- a/includes/metadata.php +++ b/includes/metadata.php @@ -40,7 +40,6 @@ function dws_tpl_get_plugin_slug(): string { */ function dws_tpl_get_plugin_name(): string { $name = get_file_data( DWS_TPL_FILE, array( 'Name' => 'Plugin Name' ) )['Name']; - return 0 < did_action( 'init' ) ? __( $name, 'dws-plugin-template' ) : $name; // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText -- The header's name reaches the POT through make-pot's header extraction. } diff --git a/includes/options.php b/includes/options.php index 95365fe..79b6dcd 100644 --- a/includes/options.php +++ b/includes/options.php @@ -86,7 +86,6 @@ */ function dws_tpl_get_bool_option( string $key ): bool { $default = dws_tpl_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); } @@ -102,7 +101,6 @@ function dws_tpl_get_bool_option( string $key ): bool { */ function dws_tpl_get_int_option( string $key ): int { $default = dws_tpl_get_option_default( $key ); - return filter_var( get_option( $key, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); } diff --git a/src/Plugin.php b/src/Plugin.php index 9e8e58c..3befb4b 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -191,7 +191,6 @@ protected function compose(): bool { $this->kernel = new PluginKernel( new ContainerBuilder()->addDefinitions( $this->get_definitions( $this->logger, $this->scheduler ) )->build() ); $this->kernel->boot( self::COMPONENTS ); - return true; } diff --git a/src/Settings.php b/src/Settings.php index af527dd..ad36ea3 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -116,7 +116,6 @@ public function add_settings_tab( array $pages ): array { ), $this ); - return $pages; } diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index a59ef55..0d65b66 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -200,7 +200,6 @@ protected function place_orders( int $count, string $status = 'completed' ): voi protected function print(): string { \ob_start(); ( new AccountDashboardGreeting() )->print_greeting(); - return (string) \ob_get_clean(); } } diff --git a/tests/Integration/MetadataTest.php b/tests/Integration/MetadataTest.php index 1a3d70c..0deab32 100644 --- a/tests/Integration/MetadataTest.php +++ b/tests/Integration/MetadataTest.php @@ -51,7 +51,6 @@ protected static function get_listed_header( string $header ): string { protected static function translate_name(): \Closure { $name = self::get_listed_header( 'Name' ); - return static fn ( string $translation, string $text ): string => $name === $text ? self::TRANSLATION : $translation; } } diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index 6831ca1..2b24722 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -132,7 +132,6 @@ public function test_a_whole_order_count_of_at_least_one_is_stored_as_its_digits protected function render(): \Dom\HTMLDocument { \ob_start(); \WC_Admin_Settings::output_fields( self::$tab->get_settings_for_section( '' ) ); - return \Dom\HTMLDocument::createFromString( '' . \ob_get_clean() . '', \LIBXML_NOERROR ); } diff --git a/tests/Support/OrderStorageTrait.php b/tests/Support/OrderStorageTrait.php index 5c55577..1d75a7f 100644 --- a/tests/Support/OrderStorageTrait.php +++ b/tests/Support/OrderStorageTrait.php @@ -34,7 +34,6 @@ protected function create_order( array $args = array() ): \WC_Order { $order = wc_create_order( $args + array( 'status' => 'pending' ) ); self::assertInstanceOf( \WC_Order::class, $order ); $this->orders[] = $order->get_id(); - return $order; } diff --git a/tests/Support/RunsRequestsTrait.php b/tests/Support/RunsRequestsTrait.php index 5f7fd84..19cf52e 100644 --- a/tests/Support/RunsRequestsTrait.php +++ b/tests/Support/RunsRequestsTrait.php @@ -7,7 +7,6 @@ protected static function request( string $php, string ...$flags ): array { $arguments = \implode( '', \array_map( static fn ( string $flag ): string => ' ' . \escapeshellarg( $flag ), $flags ) ); \exec( 'wp --path=' . \escapeshellarg( ABSPATH ) . "$arguments eval " . \escapeshellarg( $php ) . ' 2>&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 ), From 422fde224796ca24c30b641e87b8908e322e3d3b Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:14:37 +0100 Subject: [PATCH 18/28] Call methods on new objects without parentheses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Style guide §5 calls a method on a new object in PHP 8.4's bare form outside the PHP 7.4 files, and six test lines still wrapped the new expression in parentheses (X255). Assisted-by: Claude Code:claude-opus-5-5 --- tests/Integration/Greeting/AccountDashboardGreetingTest.php | 6 +++--- tests/Integration/SettingsTest.php | 4 ++-- tests/Unit/FootprintTest.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/Integration/Greeting/AccountDashboardGreetingTest.php b/tests/Integration/Greeting/AccountDashboardGreetingTest.php index 0d65b66..c800e29 100644 --- a/tests/Integration/Greeting/AccountDashboardGreetingTest.php +++ b/tests/Integration/Greeting/AccountDashboardGreetingTest.php @@ -76,12 +76,12 @@ public function test_the_greeting_hooks_the_account_dashboard_and_its_style(): v } public function test_the_greeting_style_loads_on_the_account_dashboard_only(): void { - ( new AccountDashboardGreeting() )->enqueue_style(); + new AccountDashboardGreeting()->enqueue_style(); self::assertFalse( wp_style_is( 'dws-plugin-template-greeting' ) ); add_filter( 'woocommerce_is_account_page', '__return_true' ); try { - ( new AccountDashboardGreeting() )->enqueue_style(); + new AccountDashboardGreeting()->enqueue_style(); } finally { remove_filter( 'woocommerce_is_account_page', '__return_true' ); } @@ -199,7 +199,7 @@ protected function place_orders( int $count, string $status = 'completed' ): voi protected function print(): string { \ob_start(); - ( new AccountDashboardGreeting() )->print_greeting(); + new AccountDashboardGreeting()->print_greeting(); return (string) \ob_get_clean(); } } diff --git a/tests/Integration/SettingsTest.php b/tests/Integration/SettingsTest.php index 2b24722..66fb21c 100644 --- a/tests/Integration/SettingsTest.php +++ b/tests/Integration/SettingsTest.php @@ -62,7 +62,7 @@ public static function whole_order_counts(): array { #[After] protected function forget_the_rejections(): void { - ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->setValue( null, array() ); + new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->setValue( null, array() ); } public function test_the_tab_shows_the_greeting_and_data_fields_with_their_defaults(): void { @@ -118,7 +118,7 @@ public function test_an_order_count_that_is_not_a_whole_number_of_at_least_one_k $this->save( array( DWS_TPL_OPTION_GREETING_ORDERS => $posted ) ); self::assertSame( '4', get_option( DWS_TPL_OPTION_GREETING_ORDERS ) ); - self::assertSame( array( 'Orders before the greeting was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.' ), ( new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' ) )->getValue() ); + self::assertSame( array( 'Orders before the greeting was not saved and keeps its previous value. The other changes were saved. Enter a whole number of 1 or more, such as 3.' ), new \ReflectionProperty( \WC_Admin_Settings::class, 'errors' )->getValue() ); } #[DataProvider( 'whole_order_counts' )] diff --git a/tests/Unit/FootprintTest.php b/tests/Unit/FootprintTest.php index 12b1f9b..b4175a0 100644 --- a/tests/Unit/FootprintTest.php +++ b/tests/Unit/FootprintTest.php @@ -17,7 +17,7 @@ public function test_the_prefix_has_one_segment_after_dws_so_no_prefix_in_its_fa } public function test_every_kind_is_one_the_uninstall_engine_removes(): void { - $doc = (string) ( new \ReflectionClass( Uninstaller::class ) )->getDocComment(); + $doc = (string) new \ReflectionClass( Uninstaller::class )->getDocComment(); self::assertSame( 1, \preg_match( '/@phpstan-type Footprint (.+)$/m', $doc, $type ) ); \preg_match_all( '/(\w+)\?:/', $type[1], $kinds ); From adf52da06b21d190d5e5aae802d8d5687f4a2604 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:15:20 +0100 Subject: [PATCH 19/28] Say self-updates reach a site only from a public repository The scaffold README and ARCHITECTURE.md promised later releases through WordPress's update check, but the bundled updater calls GitHub's API without authentication, so a plugin whose repository is private never offers its releases to the sites that run it. Both now say updates arrive while the repository is public, and why (X259's A636). Assisted-by: Claude Code:claude-opus-5-5 --- README.scaffold.md | 2 +- docs/ARCHITECTURE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.scaffold.md b/README.scaffold.md index a0ddf4f..82d935b 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -8,7 +8,7 @@ The entry file's headers state the floors: `Requires PHP`, `Requires at least` f ## Installation -Download `dws-plugin-template.zip` from the repository's latest release, then upload it under Plugins → Add New Plugin → Upload Plugin. Later releases arrive through WordPress's own update check, from the repository the entry file's `Update URI` names. +Download `dws-plugin-template.zip` from the repository's latest release, then upload it under Plugins → Add New Plugin → Upload Plugin. Later releases arrive through WordPress's own update check, from the repository the entry file's `Update URI` names, while that repository is public, since the check calls GitHub's API without authentication. ## Development diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 8fc4560..e5ac3ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -42,4 +42,4 @@ The request that activates the plugin does not boot it, because WordPress includ ## Prefixing and distribution -Every development install runs php-scoper through wordpress-configs, which copies the framework, PHP-DI and their dependencies into `vendor-prefixed/` under `DeepWebSolutions\PluginTemplate\Scoped\`, so each plugin carries its own copy and never meets another plugin's. The release workflow builds `dws-plugin-template.zip` from a production install, checks it with Plugin Check, the archive and header checks, activates it on a site and runs the end-to-end suite against it. A pull request rehearses the same build without publishing. A tag publishes a GitHub release, which the bundled updater offers through WordPress's own update check while the `Update URI` header points at the repository. +Every development install runs php-scoper through wordpress-configs, which copies the framework, PHP-DI and their dependencies into `vendor-prefixed/` under `DeepWebSolutions\PluginTemplate\Scoped\`, so each plugin carries its own copy and never meets another plugin's. The release workflow builds `dws-plugin-template.zip` from a production install, checks it with Plugin Check, the archive and header checks, activates it on a site and runs the end-to-end suite against it. A pull request rehearses the same build without publishing. A tag publishes a GitHub release, which the bundled updater offers through WordPress's own update check while the `Update URI` header points at the repository and the repository is public, since the check calls GitHub's API without authentication. From cf99d0ca52c0f0f94e20a2bcfa6667ac24d2c7e9 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:15:35 +0100 Subject: [PATCH 20/28] Warn that wp-env publishes the development site on the network wp-env binds its sites to every network interface with fixed development credentials, which a8csp's template warns about after its setup command and this template did not. Both READMEs now say so after npm run setup (X281's A720). They and tests/README.md also said to install Chromium once per machine, while a Playwright update can need a new browser revision; each now says to install it initially and after such an update (X259's A638). Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 4 +++- README.scaffold.md | 4 +++- tests/README.md | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ca0a3d7..70dc9e5 100644 --- a/README.md +++ b/README.md @@ -43,13 +43,15 @@ Install the dependencies, prefix the framework into `vendor-prefixed/`, build th npm run setup ``` +wp-env publishes the development site on every network interface, with fixed development credentials, so other devices on the local network can reach it. + Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integration suites on the tests site at : ```sh composer quality-check ``` -Install the browser the end-to-end suite runs in, once per machine, then run the suite: +Install the browser the end-to-end suite runs in, initially and after a Playwright update that needs a new browser revision, then run the suite: ```sh npx playwright install chromium diff --git a/README.scaffold.md b/README.scaffold.md index 82d935b..1acd02a 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -20,6 +20,8 @@ Install the dependencies, prefix the framework into `vendor-prefixed/`, build th npm run setup ``` +wp-env publishes the development site on every network interface, with fixed development credentials, so other devices on the local network can reach it. + Rebuild the blocks and assets on every change: ```sh @@ -32,7 +34,7 @@ Run the manifest and changelog checks, PHPCS, PHPStan, and the Unit and Integrat composer quality-check ``` -Install the browser the end-to-end suite runs in, once per machine, then run the suite against the tests site: +Install the browser the end-to-end suite runs in, initially and after a Playwright update that needs a new browser revision, then run the suite against the tests site: ```sh npx playwright install chromium diff --git a/tests/README.md b/tests/README.md index 7818345..8952d58 100644 --- a/tests/README.md +++ b/tests/README.md @@ -40,7 +40,7 @@ Start the multisite site, build the scripts and run the Integration suite's `mul composer test:integration:multisite ``` -Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and, once per machine, Playwright's Chromium: +Run the end-to-end suite, which starts the tests site when it is down and needs the built scripts and Playwright's Chromium, installed initially and after a Playwright update that needs a new browser revision: ```sh npx playwright install chromium From 4a3234ff1ff84cd0b8f6aa1e28be069d471172e6 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:15:58 +0100 Subject: [PATCH 21/28] Correct what the docs say the template does Several sentences misstated what they describe: - the prefix rule said no plugin's prefix starts with another's, which dws_ao and dws_aowc break, where the rule keeps each {prefix}_ apart (X259's A630); - convention 1 left nullsafe calls out of what includes/ cannot use on PHP 7.4 (A631); - the scaffold's build-gate bullet did not say PartialDeployTest's rows hide the same files (A632); - the separate-process limitation named only the cache flush, while most such rows restore in the child (A633); - tests/README.md left out the safeguard recipe's test and Quality's fill-in rehearsal (A634); - recipes.md and ARCHITECTURE.md said no check covers the footprint, though FootprintTest covers every option constant (A637); - the scaffold credited WordPress with the WooCommerce version floor's notice, which the plugin shows (X266's A667). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 4 ++-- README.md | 2 +- README.scaffold.md | 4 ++-- docs/ARCHITECTURE.md | 2 +- docs/recipes.md | 2 +- tests/README.md | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a949a43..f2f75bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,7 +52,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru ## Conventions -1. **Placement.** `src/` holds only the composition root and its components. Code that registers no hooks, such as keys, readers, the footprint, migration steps and asset registrars, is `dws_tpl_` functions and `DWS_TPL_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match` or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. +1. **Placement.** `src/` holds only the composition root and its components. Code that registers no hooks, such as keys, readers, the footprint, migration steps and asset registrars, is `dws_tpl_` functions and `DWS_TPL_` constants in `includes/`, which parses on PHP 7.4: no `mixed` or union types, typed constants, `match`, nullsafe calls or first-class callables there. Container definitions are composition and stay in `Plugin::get_definitions()`. 2. **Variants.** A component that exists only in one build variant is a child in a branch of its parent's `get_child_component_classes()`, never a second entry in `COMPONENTS`. 3. **Request context.** `is_admin()` is false on REST requests and true in admin-ajax ones, so an admin-only feature decides the request context in its callbacks, never in `should_load()`. 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. @@ -78,5 +78,5 @@ The plugin's contract is the elements tagged `@api`, the hooks it fires, and its ## Limitations - The fill-in pushes with the workflow's own token, which GitHub refuses for workflow changes, so it never edits `.github/workflows/`; the workflows read the slug from `composer.json`'s `extra.text-domain`. -- Integration and end to end share the tests site, so a row that changes shared data in a separate process flushes the parent's object cache. +- Integration and end to end share the tests site, so a row that changes shared data in a separate process restores it there, or flushes the parent's object cache in `#[AfterClass]`. - The release zip carries the TypeScript and SCSS sources beside the built files, since a plugin's repository may be private and the zip is then the only place its users find them. diff --git a/README.md b/README.md index 70dc9e5..fdf122b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Then turn on private vulnerability reporting and Dependabot alerts in the new re ## 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 22 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 prefix is `dws_` and one segment of up to 22 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. diff --git a/README.scaffold.md b/README.scaffold.md index 1acd02a..4850bcc 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -4,7 +4,7 @@ TODO: one sentence saying why this plugin exists, its philosophy rather than a l ## Requirements -The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. On a site below them, WordPress shows its own notice and none of the plugin's features load. +The entry file's headers state the floors: `Requires PHP`, `Requires at least` for WordPress, and `Requires Plugins` with `WC requires at least` for WooCommerce. WordPress shows its own notice below the PHP or WordPress floor or without WooCommerce, and the plugin shows its own below WooCommerce's version floor; either way, none of the plugin's features load. ## Installation @@ -63,7 +63,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 `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. +- 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. `PartialDeployTest` hides each file the gate checks in a row of its own, so its rows follow the gate. - `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 e5ac3ec..8b26044 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -36,7 +36,7 @@ The request that activates the plugin does not boot it, because WordPress includ - **Options** are prefixed constants in `includes/options.php`, read only through its typed readers, which guard each stored value and fall back to the key's default. Translated defaults come from `dws_tpl_get_option_text()` after `init`; 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. +- **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 beyond the option constants `FootprintTest` covers. - **Deactivation** unschedules every hook in the footprint's `cron_hooks` and `as_hooks` on the current site. - **Uninstall** runs `uninstall.php`, which calls the framework's uninstall engine over the footprint, with WooCommerce's order meta types, on every site whose `dws_tpl_delete_data` setting consents; a site that never consented keeps its data. Below the floors or with an incomplete build, a web request deletes the files, keeps the data and logs why, while WP-CLI fails and keeps both for a rerun. When the engine cannot remove everything, a web request ends with its reason before WordPress deletes the files, and the consent stays for the next uninstall. diff --git a/docs/recipes.md b/docs/recipes.md index f2ab4d8..a2701b7 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,6 +1,6 @@ # Recipes -These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces. Options, transients and user meta under the plugin's `dws_tpl_` prefix are in the footprint already; meta on posts, terms, comments or orders needs its own `meta_prefixes` entry per object type. +These are the changes most plugins built from this template make, each as the files to add or change. A recipe that stores data lists its keys for `includes/footprint.php`, which no check enforces beyond the option constants `FootprintTest` covers. Options, transients and user meta under the plugin's `dws_tpl_` prefix are in the footprint already; meta on posts, terms, comments or orders needs its own `meta_prefixes` entry per object type. - [A safeguard](#a-safeguard) - [A one-time notice](#a-one-time-notice) diff --git a/tests/README.md b/tests/README.md index 8952d58..48d11a2 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, 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 | +| 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, the safeguard recipe's own test, 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, the example block inserted from the editor's command palette and published in its Outlined style, 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/`. @@ -15,7 +15,7 @@ PHPUnit runs both PHP suites in random order, so each row sets up what it needs ## Environments - `.wp-env.json` is the development site on , as `local`, where the framework rethrows boot and migration failures. -- `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database flushes the parent's object cache, and the end-to-end specs set their own preconditions, because either suite may run first. +- `.wp-env.tests.json` is the tests site on , as `production`, which the Integration and end-to-end suites share. A row whose separate process changes the database restores it there, or flushes the parent's object cache in `#[AfterClass]`, and the end-to-end specs set their own preconditions, because either suite may run first. - `.wp-env.multisite.json` is a network on , as `production`, with WooCommerce and the plugin network-activated, where the Integration suite's `multisite` group runs and the default run skips it. Each starts WordPress on PHP 8.5 with WooCommerce from `vendor/` and its order tables on. @@ -58,7 +58,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; 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. +- **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, and on the template it rehearses the fill-in at the longest prefix it accepts, then lints, unit-tests and builds the filled-in plugin. ## Conventions From 31654e2c10bc4ae0cefb0cd219becde22a1fbda7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:16:24 +0100 Subject: [PATCH 22/28] Spell out the checks before a pull request and the release steps CONTRIBUTING named no local checks, unlike the framework's, used a bash fence where the docs use sh, and its release paragraph broke off at "a tag either differs from". It ended at pushing a tag, leaving out that the release publishes only a commit whose Quality and Tests push runs passed, that a dispatched run rehearses it, and that the tag is signed, all of which a8csp's template documents. A "Before opening a pull request" block lists the local equivalents of CI, the fences are sh, the sentence reads "a tag that either differs from", and the release steps run from the green trunk commit through an optional rehearsal to git tag -s and the push (X259's A631, X273's A696, X280's A717, X281's A719). Assisted-by: Claude Code:claude-opus-5-5 --- CONTRIBUTING.md | 24 ++++++++++++++++++++++-- README.md | 2 +- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62dd805..00f853d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,23 @@ # Contributing +## Before opening a pull request + +Install the dependencies on the PHP, Node and npm versions the README lists, then run the local equivalents of the CI checks: + +```sh +npm run setup +npm run quality-check:all +composer test:integration:multisite +npm run audit +actionlint +zizmor .github/ +``` + ## Changelog entries Each user-visible change adds one fragment, so pull requests never conflict on `CHANGELOG.md`: -```bash +```sh composer changelog:add ``` @@ -24,4 +37,11 @@ The first release has no earlier version to count from, so it names its own: composer changelog:write -- --use-version=1.0.0 ``` -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. It sets each `blocks/src/*/block.json`'s `version` too, which WordPress puts in the block's stylesheet URL and the Unit suite holds to the header. Pushing the tag `v` on that commit starts the release. +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 that either differs from. It sets each `blocks/src/*/block.json`'s `version` too, which WordPress puts in the block's stylesheet URL and the Unit suite holds to the header. + +Land the release commit on trunk and wait for its Quality and Tests runs to pass, since the release publishes only a commit whose runs passed. To rehearse, run the Release workflow on trunk from the Actions tab, which checks those runs, builds and tests the zip and publishes nothing. Then sign a tag on the commit and push it, which starts the release: + +```sh +git tag -s v -m v +git push origin v +``` diff --git a/README.md b/README.md index fdf122b..fa820b5 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Update the dependencies within their constraints, and `@wordpress/*` to npm's la 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 but `changelog/initial-release`, the plugin's first entry, which the fill-in keeps while it deletes any other. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the changelog, and `SECURITY.md` how to report a vulnerability. +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 but `changelog/initial-release`, the plugin's first entry, which the fill-in keeps while it deletes any other. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the checks before a pull request, the changelog and releases, and `SECURITY.md` how to report a vulnerability. ## License From 1cdfe19ccfde03aff305655ce00deddfa3552fb2 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:16:30 +0100 Subject: [PATCH 23/28] Say which release receives security fixes The security policy did not say which releases get fixes, which the framework's states. It now says only the latest release does, naming no version, since every plugin made from the template starts at 1.0.0 and the template itself never releases (X295's A767, as X360 words it for the template). Assisted-by: Claude Code:claude-opus-5-5 --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index b28bea2..e76a830 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,10 @@ Report it privately through [GitHub's security advisory form](https://github.com/ahegyes/wordpress-plugin-template/security/advisories/new), never in a public issue. The first response comes within 7 days. +## Supported versions + +Only the latest release receives security fixes. + ## Scope In scope: the plugin's PHP, JavaScript and CSS, its prefixing configuration (`scoper.inc.php`) and its CI workflows. From 5177abacd3e9522d697b3c744d38be5d95a96f25 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:16:52 +0100 Subject: [PATCH 24/28] Make the issue forms and pull request template plugin-neutral The forms described the unmodified template, asked about forks and scoping internals, and suggested WordPress 7.0, below the template's floor, and every plugin made from the template inherited them; the scaffold README wrongly said their wording describes the plugin's demo. Both forms and the pull request template now fit the template and any plugin made from it, as a8csp's do: their areas are the runtime, settings, blocks and scripts, CI workflows (.github/workflows/) and release, and documentation, their version placeholders read x.y.z, and their words replace symbols. The pull request template's breaking changes name the plugin's contract, and the scaffold README keeps only SECURITY.md's scope to decide by hand (X202's A255, X280's A718). Assisted-by: Claude Code:claude-opus-5-5 --- .github/ISSUE_TEMPLATE/bug_report.yml | 41 +++++++++++++--------- .github/ISSUE_TEMPLATE/feature_request.yml | 32 ++++++----------- .github/PULL_REQUEST_TEMPLATE.md | 11 +++--- README.scaffold.md | 2 +- 4 files changed, 40 insertions(+), 46 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index f77ccff..7d82820 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,5 +1,5 @@ name: Bug report -description: Report a defect in the plugin template (the unmodified template, not a forked plugin). +description: Report a defect in the plugin. labels: [bug, triage] body: - type: dropdown @@ -7,21 +7,20 @@ body: attributes: label: Affected area options: - - 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) - - wp-env / integration test infrastructure - - Documentation / readme - - Other / unsure + - Runtime (boot, components, framework wiring) + - Settings + - Blocks and scripts + - CI workflows (.github/workflows/) and release + - Documentation + - Other or unsure validations: required: true - type: input - id: commit + id: version attributes: - label: Plugin template commit / SHA - placeholder: 'trunk@' + label: Plugin version or commit + placeholder: 'x.y.z or trunk@' validations: required: true @@ -29,7 +28,7 @@ body: id: php-version attributes: label: PHP version - placeholder: '8.5.0' + placeholder: 'x.y.z' validations: required: true @@ -37,16 +36,24 @@ body: id: wp-version attributes: label: WordPress version - placeholder: '7.0' + placeholder: 'x.y.z' + validations: + required: true + + - type: input + id: wc-version + attributes: + label: WooCommerce version, if active + placeholder: 'x.y.z' - type: textarea id: reproduction attributes: label: Steps to reproduce placeholder: | - 1. Use this template via "Use this template" or git clone - 2. Run `composer packages-install` / `npm install` / `npm run wp-env:start` - 3. Observe Z + 1. Activate the plugin. + 2. Go to … + 3. See … validations: required: true @@ -67,5 +74,5 @@ body: - type: textarea id: logs attributes: - label: Relevant logs / stack traces / CI run URL + label: Relevant logs, stack traces or a CI run URL render: text diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 7f6f04b..369feae 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Propose a new capability for the plugin template scaffold. +description: Propose a new capability for the plugin. labels: [enhancement, triage] body: - type: dropdown @@ -7,13 +7,12 @@ body: attributes: label: Target area options: - - Plugin runtime scaffold (composition root, components, framework wiring) - - Composer scoping pipeline - - Changelog tooling - - CI workflows - - wp-env / integration test infrastructure - - Documentation / placeholder convention - - New area / unsure + - Runtime (boot, components, framework wiring) + - Settings + - Blocks and scripts + - CI workflows (.github/workflows/) and release + - Documentation + - New area or unsure validations: required: true @@ -21,7 +20,7 @@ body: id: problem attributes: label: Problem - description: What is the template-user-facing problem this would solve? + description: What problem would this solve, and for whom? validations: required: true @@ -29,7 +28,7 @@ body: id: proposal attributes: label: Proposed solution - description: How would you imagine this looking? Code or YAML samples welcome. + description: How would it work? Code samples are welcome. validations: required: true @@ -37,15 +36,4 @@ body: id: alternatives attributes: label: Alternatives considered - description: What other approaches did you weigh? Why this one? - - - type: dropdown - id: breaking - attributes: - label: Breaking change for existing forks? - options: - - 'No (additive — existing forks can ignore the new feature)' - - 'Yes (existing forks need to merge / adapt)' - - 'Unsure' - validations: - required: true + description: What other approaches did you weigh, and why this one? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e842b93..bc2a1d0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,24 +1,23 @@ # Summary - + ## Type of change - [ ] Bug fix - [ ] New feature - [ ] Refactor -- [ ] Build / CI / tooling +- [ ] Build, CI or tooling - [ ] Documentation - [ ] Dependency update ## Checklist - [ ] Changelog fragment added (`composer changelog:add`) -- [ ] Tests added/updated +- [ ] Tests added or updated - [ ] `composer quality-check` passes locally - [ ] Plugin header updated if runtime requirements changed -## Breaking changes for forks - - +## Breaking changes + diff --git a/README.scaffold.md b/README.scaffold.md index 4850bcc..24d0536 100644 --- a/README.scaffold.md +++ b/README.scaffold.md @@ -71,4 +71,4 @@ Whichever parts go, four settings follow what is left, since a plugin removes th Still to decide by hand: - WooCommerce's order tables: `dws_tpl_declare_compatibility()` in `functions.php` declares the plugin incompatible until its order code is verified on both storages. -- `SECURITY.md`'s scope and the issue forms in `.github/ISSUE_TEMPLATE/`, whose wording describes this plugin's demo. +- `SECURITY.md`'s scope, whose first out-of-scope line names the plugins generated from the template. From 5e6daf1bee056b21baaada501aa21f8b7947e7d7 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:20:21 +0100 Subject: [PATCH 25/28] Correct the recipes that break or mislead when followed Several recipes broke a plugin that followed them or misstated what they do: - the notice recipe's occurrence id was a timestamp, which two notices in one second share, with a comment that misstated why each occasion needs a new id (X257's A625, A626); - the Without WooCommerce recipe's functions.php snippet dropped the includes loop, repeated the settings page with a second body for dws_tpl_get_int_setting(), replaced options.php with one that lacks the reader the recurring-jobs recipe calls, and left PartialDeployTest out of its test steps (A619-A621, A623); - the hook-rename recipe shipped no deprecated alias and a major fragment, against the rename rule every plugin follows (A622); - the recurring-jobs recipe said concurrent requests skip, though its read and write are not atomic, and both activation recipes declared their own dws_tpl_activate() (A620, A624); - the snippets called PHP's own functions unqualified, built option keys from Plugin::PREFIX, wrote the payment notes key as a literal field id and the prune hook as a second literal (X258's A627-A629, X152); - JSON blocks were indented with spaces (X323). The occurrence id is 'export-' . wp_generate_uuid4(); the Without WooCommerce steps show only the hook block, add the settings-page recipe's page and reader with only the consent field and its reader, keep the option readers while a standalone option needs them, and name PartialDeployTest's consent seed; a renamed hook fires its old name through the deprecated functions until the next major, with a minor Deprecated fragment; the repair gate narrows the window and ensure_recurring() repairs a duplicate; both activation recipes share one dws_tpl_activate(); the snippets take the leading backslash, literal keys, DWS_TPL_OPTION_PAYMENT_NOTES with its default and Pruning::HOOK; and the JSON blocks use tabs. The Without WooCommerce recipe also covers what the template gained since: the multisite environment's WooCommerce mapping, the safeguards profile's WooCommerce stubs and OrderStorageTrait, and it notes that PHPStan refuses a lookup in an empty DWS_TPL_OPTION_DEFAULTS. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 302 ++++++++++++------------------------------------ 1 file changed, 73 insertions(+), 229 deletions(-) diff --git a/docs/recipes.md b/docs/recipes.md index a2701b7..2317d95 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -69,7 +69,9 @@ 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. + * Filters whether the data export stays locked. + * + * Only an explicit false, once the plugin has booted, unlocks it, so a half-loaded build never relaxes it. * * @since 1.0.0 * @version 1.0.0 @@ -187,9 +189,9 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Admin protected function report_export( int $rows ): void { $this->notices->add( new Notice( - Plugin::PREFIX . '-export-' . time(), // A viewer's delivery outlives the notice, so each occasion takes a new id. + 'export-' . wp_generate_uuid4(), /* translators: %d: The number of rows. */ - esc_html( sprintf( _n( 'The export wrote %d row.', 'The export wrote %d rows.', $rows, 'dws-plugin-template' ), $rows ) ), + esc_html( \sprintf( _n( 'The export wrote %d row.', 'The export wrote %d rows.', $rows, 'dws-plugin-template' ), $rows ) ), NoticeType::Success ) ); @@ -213,7 +215,7 @@ Add the option, its field and its default, a reader that guards the field and th * * @var string */ -const DWS_TPL_OPTION_SETTINGS = Plugin::PREFIX . '_settings'; +const DWS_TPL_OPTION_SETTINGS = 'dws_tpl_settings'; /** * The settings field that holds the limit. @@ -561,10 +563,11 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti 'title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), ), array( - 'type' => 'checkbox', - 'id' => Plugin::PREFIX . '_payment_notes', - 'title' => __( 'Payment notes', 'dws-plugin-template' ), - 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), + 'type' => 'checkbox', + 'id' => DWS_TPL_OPTION_PAYMENT_NOTES, + 'title' => __( 'Payment notes', 'dws-plugin-template' ), + 'desc' => __( 'Add a note to each order when its payment completes', 'dws-plugin-template' ), + 'default' => wc_bool_to_string( DWS_TPL_OPTION_DEFAULTS[ DWS_TPL_OPTION_PAYMENT_NOTES ] ), ), array( 'type' => 'multiselect', @@ -619,7 +622,7 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti * @return string[] */ public function add_settings_link( array $links ): array { - array_unshift( $links, sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); + \array_unshift( $links, \sprintf( '%s', esc_url( admin_url( 'admin.php?page=wc-settings&tab=checkout§ion=' . Plugin::PREFIX ) ), esc_html__( 'Settings', 'dws-plugin-template' ) ) ); return $links; } @@ -627,9 +630,19 @@ final class PaymentSettings implements ComponentInterface, SectionFieldsDefiniti } ``` -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: +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`, `false` for the checkbox and an empty array for the multiselect, whose `@var` then allows `list`, and the list reader beside the other typed readers: ```php +/** + * The option that holds 'yes' where the site adds a note to each order when its payment completes. + * + * @since 1.0.0 + * @version 1.0.0 + * + * @var string + */ +const DWS_TPL_OPTION_PAYMENT_NOTES = 'dws_tpl_payment_notes'; + /** * The option that holds the payment methods whose orders get the payment note. * @@ -664,7 +677,7 @@ WooCommerce's save drops a multiselect key of `'0'` or `''`, so each key stays t A recurring job runs a hook at an interval through the plugin's scheduler, which uses Action Scheduler where it is loaded and WP-Cron otherwise. Either can run an invocation twice or skip one, so the job's callback leaves its data consistent after a duplicate or a missed run. -Add the repair gate's option to `includes/options.php`: +Add the repair gate's option to `includes/options.php`, with its default `0` in `DWS_TPL_OPTION_DEFAULTS`: ```php /** @@ -675,10 +688,10 @@ Add the repair gate's option to `includes/options.php`: * * @var string */ -const DWS_TPL_OPTION_RECURRING_ENSURED_AT = Plugin::PREFIX . '_recurring_ensured_at'; +const DWS_TPL_OPTION_RECURRING_ENSURED_AT = 'dws_tpl_recurring_ensured_at'; ``` -Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, writing the time first so concurrent requests skip, and again when Action Scheduler asks every day: +Add the job as a component, `src/Pruning.php`, and list it in `Plugin::COMPONENTS`. It ensures the schedule on `wp_loaded` at most once an hour, and again when Action Scheduler asks every day. Writing the time first narrows the window in which two requests both ensure it, and `ensure_recurring()` repairs a duplicate chain on its next pass: ```php ensure_schedule(); } @@ -808,8 +821,8 @@ final class Pruning implements ComponentInterface { ``` Then: -- Add `Plugin::PREFIX . '/records/prune'` to `cron_hooks` in `includes/footprint.php`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. -- Delete the repair gate's option on activation, so the next request ensures the jobs: add `register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' );` to `functions.php` beside the deactivation hook, and a `dws_tpl_activate()` that calls `delete_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT )`. +- Add `Pruning::HOOK` to `cron_hooks` in `includes/footprint.php`, importing the class as the file imports `Plugin`. Deactivation unschedules every hook there through the plugin's scheduler, and uninstall clears WP-Cron's copies, while Action Scheduler's are in the footprint's `as_groups` already. +- Delete the repair gate's option on activation, so the next request ensures the jobs: add `register_activation_hook( DWS_TPL_FILE, 'dws_tpl_activate' );` to `functions.php` beside the deactivation hook, and a `dws_tpl_activate()` that calls `delete_option( DWS_TPL_OPTION_RECURRING_ENSURED_AT )`. A plugin that also takes the rewrite-rules recipe keeps one `dws_tpl_activate()` that does both. - Change an interval in code only: `ensure_recurring()` replaces a schedule whose interval differs on the next pass of the repair gate. A "run now" schedules the same hook with an argument that sets it apart, or calls `prune()` directly. ## Rewrite rules after activation @@ -836,7 +849,7 @@ function dws_tpl_activate(): void { } ``` -Add the same `delete_option( 'rewrite_rules' )` to `dws_tpl_deactivate()`, so the next request rebuilds the rules without them. Both act on the current site only, as WordPress's own activation does; a network activation leaves the other sites' rules until they next rebuild them. +A plugin that also takes the recurring-jobs recipe keeps one `dws_tpl_activate()` that does both. Add the same `delete_option( 'rewrite_rules' )` to `dws_tpl_deactivate()`, so the next request rebuilds the rules without them. Both act on the current site only, as WordPress's own activation does; a network activation leaves the other sites' rules until they next rebuild them. ## A script module @@ -876,10 +889,27 @@ The build writes `view.js` with a `view.asset.php` whose `type` is `module`, and A public hook's name is part of the plugin's contract, as its arguments, its options and its REST routes are. To rename one in a released plugin: 1. Rename the literal at every `do_action()` or `apply_filters()` and in its docblock, which keeps its `@since` and moves its `@version` to the release. -2. Add a changelog fragment with `composer changelog:add` that names the old and the new hook, with a major significance. -3. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. +2. Fire the old name too, until the next major, through `do_action_deprecated()` or `apply_filters_deprecated()` with the release's version and the new name as its replacement. The old name goes first, so a filter's callbacks on the new name see the final value: -Ship no deprecated alias: the major release is the migration path. +```php +$restricted = apply_filters_deprecated( 'dws_tpl/export/should_restrict', array( true ), '1.1.0', 'dws_tpl/data_export/should_restrict' ); + +/** + * Filters whether the data export stays locked. + * + * Only an explicit false, once the plugin has booted, unlocks it, so a half-loaded build never relaxes it. + * + * @since 1.0.0 + * @version 1.1.0 + * + * @param bool $restricted Whether the data export stays locked. + */ +$restricted = apply_filters( 'dws_tpl/data_export/should_restrict', $restricted ); +``` + +3. Add a changelog fragment with `composer changelog:add` that names the old and the new hook, with a minor significance and the Deprecated type. +4. Migrate stored data that holds the name, such as a scheduled job's hook, in a migration step that converges from any partial state: unschedule the old name, then ensure the new one. +5. In the next major, delete the deprecated call, with a major fragment that names the old hook as removed. ## Distribution through wordpress.org @@ -923,8 +953,8 @@ TODO: what the plugin does, for the directory's listing. ```json "changelog:write": [ - "@php vendor/bin/changelogger write", - "@php vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt" + "@php vendor/bin/changelogger write", + "@php vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt" ] ``` @@ -965,15 +995,16 @@ composer remove --dev --ignore-platform-req=php+ --no-interaction ahegyes/wp-fra - vendor/php-stubs/woocommerce-stubs/woocommerce-packages-stubs.php ``` +In `phpstan.safeguards.neon`, delete the whole `scanFiles` key, which lists only those stubs, with its comment. + 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()`: +4. In `functions.php`, delete the `FeaturesUtil` import, the `before_woocommerce_init` hook and `dws_tpl_declare_compatibility()`, so the hooks after the includes loop read: ```php -use DeepWebSolutions\PluginTemplate\Plugin; - -defined( 'ABSPATH' ) || exit; +// ... the includes loop ... +// The hooks come last, so an include that throws leaves nothing of the plugin hooked. add_action( 'plugins_loaded', 'dws_tpl_boot', 15 ); register_deactivation_hook( DWS_TPL_FILE, 'dws_tpl_deactivate' ); ``` @@ -989,7 +1020,7 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra public const array COMPONENTS = array( Scheduler::class, AdminNotices::class, - Settings::class, + SettingsPage::class, Blocks::class, ); @@ -1035,122 +1066,20 @@ use DeepWebSolutions\PluginTemplate\Scoped\DeepWebSolutions\Framework\Core\Migra "start:assets": "wp-scripts start editor=./assets/src/editor.ts --output-path=assets/build" ``` -7. Replace `src/Settings.php` with an options page under Settings, which stores both fields in one option through the Settings API: +7. Delete `src/Settings.php`, and add the settings page from [A WordPress settings page](#a-wordpress-settings-page), with the consent to delete the plugin's data as a second field of its tab: ```php -register_hooks(); - } - - /** - * {@inheritDoc} - * - * @since 1.0.0 - * @version 1.0.0 - */ - #[\Override] - public function get_options_page( string $page_id ): array { - return array( - 'parent' => 'options-general.php', - 'capability' => 'manage_options', - 'page_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), - 'menu_title' => __( 'DWS Plugin Template', 'dws-plugin-template' ), - 'tabs' => array( - array( - 'slug' => 'general', - 'title' => __( 'General', 'dws-plugin-template' ), - 'option' => DWS_TPL_OPTION_SETTINGS, - 'fields' => array( - array( - 'type' => 'number', - 'id' => DWS_TPL_SETTING_LIMIT, - 'title' => __( 'Limit', 'dws-plugin-template' ), - 'default' => (string) DWS_TPL_SETTING_DEFAULTS[ DWS_TPL_SETTING_LIMIT ], - 'custom_attributes' => array( - 'min' => '1', - 'step' => '1', - ), - 'validate' => 'dws_tpl_validate_limit', - ), array( 'type' => 'checkbox', 'id' => DWS_TPL_SETTING_DELETE_DATA, 'title' => __( 'Remove all data on uninstallation', 'dws-plugin-template' ), 'desc' => __( 'Delete the plugin\'s data when it is uninstalled, so it must be configured again if it is installed later', 'dws-plugin-template' ), ), - ), - ), - ), - ); - } - - // endregion -} ``` -8. Replace `includes/options.php` with the one settings option, its fields, their defaults, and readers that guard each field. The file stays PHP 7.4-parseable, because Quality's syntax job lints `includes/`: +8. In `includes/options.php`, delete the greeting's three keys with their defaults, text and validator, and `DWS_TPL_OPTION_DELETE_DATA`. The typed option readers stay for a standalone option, such as the recurring-jobs recipe's, with its default in `DWS_TPL_OPTION_DEFAULTS`; while the plugin has none, delete them with `DWS_TPL_OPTION_DEFAULTS`, `dws_tpl_get_option_text()` and `dws_tpl_get_option_default()`, since PHPStan refuses a lookup in an empty array. Add the settings-page recipe's settings option, field, default, reader and validator, then the consent field, its default `false` in `DWS_TPL_SETTING_DEFAULTS`, whose `@var` then reads `array`, and a reader for it: ```php - - */ -const DWS_TPL_SETTING_DEFAULTS = array( - DWS_TPL_SETTING_DELETE_DATA => false, - DWS_TPL_SETTING_LIMIT => 1, -); - /** * Returns a settings field as a boolean, which reads checkbox values such as 'yes' and 'no'. * @@ -1195,74 +1101,10 @@ const DWS_TPL_SETTING_DEFAULTS = array( * @return bool */ function dws_tpl_get_bool_setting( string $field ): bool { - $default = dws_tpl_get_setting_default( $field ); - return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); -} - -/** - * Returns a settings field as an integer. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return int - */ -function dws_tpl_get_int_setting( string $field ): int { - $default = dws_tpl_get_setting_default( $field ); - return filter_var( dws_tpl_get_setting( $field, $default ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? ( is_int( $default ) ? $default : 0 ); -} - -/** - * Returns the limit as the settings page stores it, or an error when the submission is not a whole number of at least 1. - * - * The number input sends a whole number in any form a browser accepts, such as 03 or 3.0, so the submission parses as a float. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param mixed $submitted The submitted value. - * - * @return int|WP_Error - */ -function dws_tpl_validate_limit( $submitted ) { - $limit = is_string( $submitted ) ? filter_var( $submitted, FILTER_VALIDATE_FLOAT ) : false; - if ( false === $limit || $limit < 1 || $limit >= PHP_INT_MAX || floor( $limit ) !== $limit ) { - return new WP_Error( 'invalid_number', __( 'Enter a whole number of 1 or more, such as 3.', 'dws-plugin-template' ) ); - } - - return (int) $limit; -} - -/** - * Returns a settings field's stored value, or the fallback while the settings hold none. - * - * @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. - * - * @return mixed - */ -function dws_tpl_get_setting( string $field, $fallback ) { - $settings = get_option( DWS_TPL_OPTION_SETTINGS ); - return is_array( $settings ) && array_key_exists( $field, $settings ) ? $settings[ $field ] : $fallback; -} - -/** - * Returns a settings field's default, or null for a field without one. - * - * @since 1.0.0 - * @version 1.0.0 - * - * @param string $field The field's id. - * - * @return mixed - */ -function dws_tpl_get_setting_default( string $field ) { - return DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; + $default = DWS_TPL_SETTING_DEFAULTS[ $field ] ?? null; + $stored = get_option( DWS_TPL_OPTION_SETTINGS ); + $stored = is_array( $stored ) && array_key_exists( $field, $stored ) ? $stored[ $field ] : $default; + return filter_var( $stored, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE ) ?? ( true === $default ); } ``` @@ -1304,24 +1146,26 @@ function dws_tpl_add_settings(): void { ); ``` -11. In `.wp-env.json` and `.wp-env.tests.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin; `.wp-env.json`'s command takes no `--config`: +11. In `.wp-env.json`, `.wp-env.tests.json` and `.wp-env.multisite.json`, delete the WooCommerce mapping, and let `afterStart` only activate the plugin, network-wide in the multisite one; `.wp-env.json`'s command takes no `--config`: ```json "mappings": { - "wp-content/plugins/dws-plugin-template": "." + "wp-content/plugins/dws-plugin-template": "." }, "lifecycleScripts": { - "afterStart": "node_modules/.bin/wp-env --config .wp-env.tests.json run cli wp plugin activate dws-plugin-template" + "afterStart": "node_modules/.bin/wp-env --config .wp-env.tests.json run cli wp plugin activate dws-plugin-template" } ``` 12. Adapt the tests: -- Delete `tests/EndToEnd/without-woocommerce.spec.ts`. -- `OptionsTest` reads through `dws_tpl_get_bool_setting()` and `dws_tpl_get_int_setting()` from a `DWS_TPL_OPTION_SETTINGS` row; a malformed limit reads its default 1; the validator rows call `dws_tpl_validate_limit()`, which returns integers. +- Delete `tests/EndToEnd/without-woocommerce.spec.ts` and `tests/Support/OrderStorageTrait.php`. +- `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; the option readers' rows go if step 8 deleted the readers. - `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. +- `PartialDeployTest`'s missing-engine row seeds and asserts consent through `array( DWS_TPL_SETTING_DELETE_DATA => 'yes' )` in `DWS_TPL_OPTION_SETTINGS`, and restores the row it read. +- `MultisiteTest` reads the second site's consent from its `DWS_TPL_OPTION_SETTINGS` row. - `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: +- `SettingsTest` builds `new OptionsPage( Plugin::PREFIX, new SettingsPage() )` and calls its `register_settings()` in `setUpBeforeClass()`, which first requires `wp-admin/includes/template.php`. `tearDownAfterClass()` removes the page's `pre_update_option_` and `option_page_capability_` filters and calls `unregister_setting()`. Each test resets `$GLOBALS['wp_settings_errors']`, renders through `render_page()` and saves as `options.php` does: ```php // options.php saves a submission through update_option(), whose filter the page hooks for its own option page only. From d91499267e654caf2d27916e3f50937f3fbe0a0a Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:41:03 +0100 Subject: [PATCH 26/28] Run the first release's two changelog writes apart The wordpress.org recipe makes changelog:write an array of changelogger and the readme sync, and Composer hands the arguments after -- to every command in the array, so CONTRIBUTING's first-release command also hands --use-version to the readme sync, which is changelogger's option, not the sync's. The recipe now says to run the two commands apart for the first release. Assisted-by: Claude Code:claude-opus-5-5 --- docs/recipes.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/recipes.md b/docs/recipes.md index 2317d95..f3509fa 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -958,6 +958,13 @@ TODO: what the plugin does, for the directory's listing. ] ``` +Composer hands the arguments after `--` to every command in the array, so the first release runs the two apart, and `CONTRIBUTING.md`'s first-release command becomes: + +```sh +vendor/bin/changelogger write --use-version=1.0.0 +vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt +``` + 4. Lint the file in `.github/workflows/quality.yml`: ```yaml From 1c3abafb3ab3ddf783eaa67ff4779ba9bd358ae9 Mon Sep 17 00:00:00 2001 From: Tony Hegyes <36231502+ahegyes@users.noreply.github.com> Date: Wed, 11 Nov 2026 10:52:13 +0100 Subject: [PATCH 27/28] Hook the safeguards that call WooCommerce once it has loaded Convention 7 and the safeguard recipe let a safeguard call WooCommerce APIs present since 3.0 without a guard, as if WooCommerce were loaded whenever the safeguards run. WordPress still loads the plugin while WooCommerce is inactive, as the without-WooCommerce spec shows, and the safeguards load before the boot's WooCommerce check, so such a call on a WordPress hook fatals on every request that fires it, while the safeguards' PHPStan profile passes it on WooCommerce's stubs. A WC_VERSION check at the top of the file would not help, since the plugin loads before WooCommerce. A safeguard that calls WooCommerce is now hooked from one function, which the file runs on woocommerce_loaded, fired once every plugin has loaded and never while WooCommerce is inactive, or at once where that has fired already, as on the request that activates the plugin, which includes it after plugins_loaded. The WooCommerce APIs such a safeguard calls unguarded are those present since 3.6, the release that moved that hook there (X361). Assisted-by: Claude Code:claude-opus-5-5 --- AGENTS.md | 2 +- docs/recipes.md | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f2f75bf..7c6d01c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,7 +58,7 @@ Each runs PHP 8.5 with WooCommerce from `vendor/` and its order tables on; CI ru 4. **Add-ons.** An add-on extends a base plugin only through the base plugin's documented hooks. It reads a template-built base plugin's slug, name and version only through that plugin's `{prefix}_get_plugin_*()` getters, from `plugins_loaded` on, since the add-on may load first, and behind `function_exists()`, since a base plugin below its floors or with a partial build may stay active without defining them. 5. **Request reads.** An integer or boolean is read as `filter_var( wp_unslash( $_POST['key'] ?? '' ), FILTER_VALIDATE_INT, FILTER_NULL_ON_FAILURE ) ?? $default` (`FILTER_VALIDATE_BOOL` for a boolean, where an absent key reads false, as an unchecked checkbox posts nothing), and a string through an `is_string()` guard and the field's sanitizer; never `filter_input()`, the `default` filter option or a cast on request input. 6. **Options and data.** Options are read only through `includes/options.php`'s typed readers, safeguards excepted. Everything stored lives under `dws_tpl_`, or `_dws_tpl_` for hidden meta. The footprint covers the prefix's options, transients and user meta; object meta needs a `meta_prefixes` entry per type, and anything else its own entry. -7. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file, and WooCommerce where the plugin requires it, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, and only what their hook hands them or WooCommerce APIs present since 3.0, its CRUD release, guarding anything newer with `function_exists()` or `method_exists()`. `phpstan.safeguards.neon` checks the WordPress half with WooCommerce's stubs loaded; review checks the WooCommerce half, which no tool reads. +7. **Safeguards** live in `includes/safeguards.php`, call WordPress, that file, and WooCommerce where the plugin requires it, and relax only through their own filter, through `dws_tpl_is_relaxed()` (`docs/recipes.md`). The entry loads them before the floor check, so they parse on PHP 7.4 and call only what their hook hands them, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked from one function, which the file runs on `woocommerce_loaded`, or at once where that has fired already, as on the request that activates the plugin; WooCommerce fires it, since 3.6, once every plugin has loaded. `phpstan.safeguards.neon` reads the whole file, with WooCommerce's stubs loaded, and checks the WordPress floor; review checks the WooCommerce floor, which no tool reads. 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. diff --git a/docs/recipes.md b/docs/recipes.md index f3509fa..ccd2f11 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -19,7 +19,7 @@ These are the changes most plugins built from this template make, each as the fi A safeguard is a restriction that holds even when the rest of the plugin fails to load, such as a capability that stays locked unless the plugin unlocks it. It lives in `includes/safeguards.php`, which the entry file loads before the floor gate and the autoloader, so it calls only WordPress, what that file defines, and WooCommerce where the plugin requires it. The plugin relaxes it only through the safeguard's own filter, with an explicit `false`, and only after `dws_tpl/boot/succeeded`, which fires once the plugin has booted, so a half-loaded build never relaxes it. -Replace `includes/safeguards.php` with the helper and the safeguard. The file runs on any runtime that parses the plugin, so it must parse on PHP 7.4 and call only WordPress APIs present at the oldest WordPress the bootstrap package runs on, and only what its hook hands it or WooCommerce APIs present since 3.0, its CRUD release, guarding anything newer with `function_exists()` or `method_exists()`; `phpstan.safeguards.neon` checks the WordPress half: +Replace `includes/safeguards.php` with the helper and the safeguard. The file runs on any runtime that parses the plugin, so it must parse on PHP 7.4 and call only what its hook hands it, WordPress APIs present at the oldest WordPress the bootstrap package runs on and WooCommerce APIs present since 3.6, guarding anything newer with `function_exists()` or `method_exists()`. WordPress loads the plugin while WooCommerce is inactive, so a safeguard that calls WooCommerce is hooked only once WooCommerce has loaded, as shown after the file. `phpstan.safeguards.neon` checks the WordPress floor, and review the WooCommerce one: ```php Date: Wed, 11 Nov 2026 11:33:53 +0100 Subject: [PATCH 28/28] Release the first version from the changelog's unreleased block Staging the first entry as changelog/initial-release removed CHANGELOG.md's "1.0.0 - unreleased" block, so the release rehearsal that runs on every pull request found no section for the entry's 1.0.0 and failed, here and in every plugin made from the template until its first release. The block was dropped because a plain changelog:write puts a new version above it; changelogger's --amend dates the block in place instead and adds any pending fragments to it. CHANGELOG.md keeps the block, the fragment goes and the fill-in deletes every fragment again, and CONTRIBUTING and the wordpress.org recipe release the first version with --amend (X363, amending X259's A635). CHANGELOG.md's prologue points at CONTRIBUTING's release steps instead of naming a plain write. Assisted-by: Claude Code:claude-opus-5-5 --- .github/scripts/fill-in-scaffold.sh | 3 +-- CHANGELOG.md | 7 ++++++- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- changelog/initial-release | 4 ---- docs/recipes.md | 2 +- 6 files changed, 12 insertions(+), 12 deletions(-) delete mode 100644 changelog/initial-release diff --git a/.github/scripts/fill-in-scaffold.sh b/.github/scripts/fill-in-scaffold.sh index 76251a4..e3de119 100755 --- a/.github/scripts/fill-in-scaffold.sh +++ b/.github/scripts/fill-in-scaffold.sh @@ -85,8 +85,7 @@ perl -pi -e 'BEGIN { ( $description = $ENV{DESCRIPTION} ) =~ s/(["\\])/\\$1/g } # shellcheck disable=SC2016 perl -pi -e 's/^TODO: one sentence saying why this plugin exists.*$/$ENV{DESCRIPTION}/' README.md -# The plugin's first release writes the initial entry the template stages. -find changelog -type f ! -name '.gitkeep' ! -name 'initial-release' -delete +find changelog -type f ! -name '.gitkeep' -delete # The sweep renamed the package, so the lock's content hash follows, or composer validate --strict fails. composer update --lock --no-install --no-scripts --no-plugins --ignore-platform-req=php+ --no-interaction diff --git a/CHANGELOG.md b/CHANGELOG.md index 2759479..0d71de3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,4 +2,9 @@ All notable changes to this plugin are documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate into a release with `composer changelog:write`. +Pending entries live in [`changelog/`](./changelog) — add via `composer changelog:add`. Aggregate them into a release as [`CONTRIBUTING.md`](./CONTRIBUTING.md) describes. + +## 1.0.0 - unreleased + +### Added +- Initial release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00f853d..3fcf980 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,10 +31,10 @@ A release writes the fragments into a new version block of `CHANGELOG.md`, whose composer changelog:write ``` -The first release has no earlier version to count from, so it names its own: +The first release dates the `1.0.0 - unreleased` block the plugin starts with, adding the fragments to it, since a plain write would put a new version above it: ```sh -composer changelog:write -- --use-version=1.0.0 +composer changelog:write -- --amend ``` 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 that either differs from. It sets each `blocks/src/*/block.json`'s `version` too, which WordPress puts in the block's stylesheet URL and the Unit suite holds to the header. diff --git a/README.md b/README.md index fa820b5..a135e1d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Create the plugin's repository from this template with GitHub's **Use this templ The workflow: - 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, the safeguard recipe's test and every changelog fragment but the initial entry, and refreshes the Composer lock's content hash; +- deletes the disabled example include, the safeguard recipe's test 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. Then turn on private vulnerability reporting and Dependabot alerts in the new repository's settings: its `SECURITY.md` sends reports through the first and development-tool advisories to the second, and a repository made from a template copies none of the template's settings. @@ -70,7 +70,7 @@ Update the dependencies within their constraints, and `@wordpress/*` to npm's la 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 but `changelog/initial-release`, the plugin's first entry, which the fill-in keeps while it deletes any other. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the checks before a pull request, the changelog and releases, and `SECURITY.md` how to report a vulnerability. +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; `CHANGELOG.md`'s `1.0.0 - unreleased` block is the plugin's first entry. `tests/README.md` describes the suites and CI, `AGENTS.md` the conventions, `CONTRIBUTING.md` the checks before a pull request, the changelog and releases, and `SECURITY.md` how to report a vulnerability. ## License diff --git a/changelog/initial-release b/changelog/initial-release deleted file mode 100644 index 8685489..0000000 --- a/changelog/initial-release +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: added - -Initial release. diff --git a/docs/recipes.md b/docs/recipes.md index ccd2f11..900a09f 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -982,7 +982,7 @@ TODO: what the plugin does, for the directory's listing. Composer hands the arguments after `--` to every command in the array, so the first release runs the two apart, and `CONTRIBUTING.md`'s first-release command becomes: ```sh -vendor/bin/changelogger write --use-version=1.0.0 +vendor/bin/changelogger write --amend vendor/bin/pronamic-changelog-md-to-wordpress-plugin-readme-txt ```