Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
# create-wp-plugin

Interactive scaffold generator for modern, production-ready WordPress plugins — a
SOLID/DI architecture (Container + Service Providers), PSR-4 autoloading,
selectable WPCS/VIP coding standards, a Brain Monkey unit suite, a
`wp-scripts plugin-zip` distribution pipeline, and a set of opt-in modules
modular bootloader architecture (one `Plugin` composition root wiring plain
`init_hooks()` modules), PSR-4 autoloading, selectable WPCS/VIP coding standards,
a Brain Monkey unit suite, a `wp-scripts plugin-zip` distribution pipeline, and a
set of opt-in modules
(REST, CPT, Cron, Caching, custom DB table, native Gutenberg blocks, Elementor,
WooCommerce, the Interactivity API, WP-CLI commands, a real-WordPress integration
suite, …) that stay freely combinable.
Expand Down Expand Up @@ -61,7 +62,7 @@ interactive multiselect. Nothing here depends on anything else.
| Module | What you get |
| --- | --- |
| `admin_settings` | Settings API page split into `Settings_Registrar` / `Settings_Repository` / a view |
| `shortcode` | A `Shortcode` provider |
| `shortcode` | A `Frontend\Shortcode` class registering one shortcode |
| `rest_api` | A `WP_REST_Controller` subclass with a permission callback |
| `ajax_handler` | Nonce + capability-guarded `admin-ajax` handler, plus the `assets/js/main.js` it enqueues |
| `cpt_taxonomy` | `Post_Types` (CPT + taxonomy), wired into activation |
Expand All @@ -71,7 +72,7 @@ interactive multiselect. Nothing here depends on anything else.
| `elementor_widget` | `Widget_Registrar` auto-discovery of `src/Widgets/*`, convention-based CSS/JS |
| `block` | Native Gutenberg block(s). Opens a sub-choice — `block:dynamic` (server-rendered via `render.php`) and/or `block:static` (`save()`-serialized). `block` / `block:all` = both. `Block_Registrar` globs `assets/build/blocks/*`, so adding more blocks later needs no PHP change |
| `interactivity` | WordPress Interactivity API store (`view.js` + Script Module, WP 6.5+) |
| `woocommerce_hooks` | Opens a sub-choice of `woo:` components: `woo:gateway`, `woo:shipping`, `woo:email`, `woo:order-status`, `woo:product-type`, `woo:blocks`, `woo:action-scheduler`, `woo:store-api`, `woo:my-account`. `woocommerce` / `woo:all` = all. Each is its own `Service_Provider` that self-excludes when WooCommerce isn't active |
| `woocommerce_hooks` | Opens a sub-choice of `woo:` components: `woo:gateway`, `woo:shipping`, `woo:email`, `woo:order-status`, `woo:product-type`, `woo:blocks`, `woo:action-scheduler`, `woo:store-api`, `woo:my-account`. `woocommerce` / `woo:all` = all. Each is a `Woo\Providers\*` class wired inside a single `class_exists( 'WooCommerce' )` guard in `boot()` |
| `cli` | `wp <prefix> status` / `wp <prefix> cache clear` (the latter iterates a `<prefix>_cache_keys` filter — no module names another's cache keys) |
| `editor_config` | `.vscode/` snippets, settings, recommended extensions |
| `integration_tests` | `wp-phpunit` suite (`composer test:integration`), `phpunit-integration.xml.dist`, a boot test, the `.wp-env.json`, and the CI integration job |
Expand Down Expand Up @@ -115,14 +116,18 @@ composer test:integration
### Releasing

Every scaffold gets a `package.json` whose `files` field is the single source of
truth for what ships (there is no `.distignore`). Order matters:
truth for what ships (there is no `.distignore`). `npm run plugin-zip` runs
`composer prepare-dist` (`composer install --no-dev --optimize-autoloader`)
itself, so the production autoloader always exists before the archive is built:

```bash
npm install && npm run build # only if there's a JS pipeline
composer install --no-dev --optimize-autoloader
npm run plugin-zip # -> <slug>.zip, via @wordpress/scripts
npm install && npm run build # only if there's a JS pipeline — must run first
npm run plugin-zip # -> <slug>.zip, via @wordpress/scripts
```

Unbuilt sources under `assets/src/` are not shipped; the generated `readme.txt`
points to the repository for them.

### Adding another block

`Block_Registrar` registers every `assets/build/blocks/*` directory that has a
Expand All @@ -139,16 +144,20 @@ npm run build

## Architecture (generated plugin)

- `Plugin::create()` builds a `Core\Container` and a list of providers;
`Plugin::boot()` runs each one. Not a singleton — construct one directly with
fakes in a test.
- Providers implement `Contracts\Service_Provider`: `register()` for container
bindings only, `boot()` for WordPress hooks.
- `Contracts\Conditional::is_needed()` lets a provider self-exclude (every
WooCommerce provider skips itself when WooCommerce isn't installed).
- `Plugin` is a singleton bootloader. `Plugin::instance()->boot()` (fired on
`plugins_loaded`) runs once — a re-entry guard makes any later call a no-op.
- `boot()` is a flat list of `( new Some\Module() )->init_hooks();` lines, one per
selected module. A module is a plain class with an `init_hooks()` method that
registers its hooks — no base class, no interface, no auto-discovery. A class
runs only because `boot()` names it.
- WooCommerce modules live under `src/Woo/` and are wired inside a single
`class_exists( 'WooCommerce' )` guard in `boot()`, so the plugin is inert
without WooCommerce. Each `Woo\Providers\*` class is one such module.
- `Services` (generated only when a module needs a shared collaborator) is a
static locator of memoised singletons — `Services::set()` / `reset()` are test
seams. A module receives a service by constructor injection from `boot()`.
- `Core\Activator` / `Deactivator` implement `Contracts\Activatable` /
`Deactivatable` and resolve dependencies from the container.
- Extend without touching core files via the `<prefix>_providers` filter.
`Deactivatable` and run from the activation / deactivation hooks in the main file.
- Cross-cutting cleanup goes through filters, not hard references: `cli` and the
uninstaller iterate `<prefix>_cache_keys`; modules that cache register their
own keys.
Expand Down
5 changes: 5 additions & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -846,6 +846,9 @@ function scaffoldInto(answers, targetDir) {
has_woo: hasAnyWoo,
// The only modules that write a templates/ directory (WC template overrides).
has_wc_template_overrides: hasWooEmail || hasWooMyAccount,
// readme.txt "== Development ==" links to source when a repo URL is known;
// falls back to a fill-in placeholder otherwise.
has_author_uri: Boolean(answers.authorUri && answers.authorUri.trim()),
// Set from servicesAccessors after the module loop: when no module
// registers an accessor, Services.php (and its reset() wiring in
// Plugin_TestCase) is not generated.
Expand Down Expand Up @@ -1754,6 +1757,8 @@ ${entries.join('\n')}
console.log(' composer lint');
console.log(' composer test');
console.log(' git init && git add -A && git commit -m "scaffold"\n');
console.log('To package a release zip, see "Releasing" in README.md');
console.log(` ${templateFlags.has_webpack_build ? 'npm run build && ' : ''}npm run plugin-zip # plugin-zip runs "composer prepare-dist" itself\n`);
console.log('Note: composer install may prompt to allow dealerdirect/phpcodesniffer-composer-installer — answer yes.');
console.log(' First run note: "No composer.lock file present" is normal; Composer will generate it automatically.\n');
}
Expand Down
39 changes: 25 additions & 14 deletions templates/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,19 @@
> Note: `assets/build` is gitignored and generated during build.
{{/if}}

## Architecture & Services
This plugin uses a modular composition root: `Plugin::create()` builds a `{{NS}}\Core\Container` and a list of providers, then `Plugin::boot()` runs each one.
- Providers implement `{{NS}}\Contracts\Service_Provider` (`register()` for container bindings, `boot()` for WordPress hooks).
- A provider can optionally implement `{{NS}}\Contracts\Conditional` to self-exclude (e.g. only run when a required plugin is active).
- Additional providers can be injected without modifying core files using the `{{PREFIX}}_providers` WordPress filter.
## Project structure

- `{{SLUG}}.php` — plugin entry point: headers, constants, autoloader, bootstrap.
- `src/` — PHP classes, PSR-4 autoloaded under the `{{NS}}\` namespace.
- `languages/` — translation files.
- `tests/` — automated test suites.
- `.github/workflows/` — CI (lint + tests).
{{#if needs_build_pipeline}}
- `assets/src/` — JS/CSS sources; `npm run build` compiles them into `assets/build/`.
{{/if}}
{{#if has_wc_template_overrides}}
- `templates/` — WooCommerce template overrides.
{{/if}}

{{#if elementor_widget}}
## Elementor Widgets Convention
Expand Down Expand Up @@ -74,19 +82,22 @@ The bundled starters: `example` (dynamic, `render.php`) and/or `example-static`

## Releasing

Build a distributable zip with `@wordpress/scripts`. The order matters — the
production autoloader and built assets have to exist *before* the archive is
created:

```sh
{{#if needs_build_pipeline}}
npm install && npm run build
{{/if}}
composer prepare-dist # composer install --no-dev --optimize-autoloader
npm run plugin-zip
```

`plugin-zip` archives exactly the paths in package.json's `files` field
(`vendor/` and `assets/src/` included, per WordPress.org guidelines) into
`{{SLUG}}.zip`. There is no `.distignore` — `files` is the single source of
truth. Re-run `composer install` afterwards to restore your dev dependencies.
`npm run plugin-zip` runs `composer prepare-dist` (`composer install --no-dev
--optimize-autoloader`) first, then packages the paths listed in `package.json`'s
`files` field — including a production `vendor/` — into `{{SLUG}}.zip`. There is
no `.distignore`; `files` is the single source of truth.
{{#if needs_build_pipeline}}

Run `npm run build` first — compiled assets must exist before packaging. Unbuilt
sources under `assets/src/` are not shipped; `readme.txt` points to the
repository for them.
{{/if}}

Afterwards, run `composer install` to restore your dev dependencies.
5 changes: 2 additions & 3 deletions templates/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,10 @@
"templates",
"assets/build",
"assets/css",
"assets/js",
"assets/src"
"assets/js"
],
"scripts": {
"plugin-zip": "wp-scripts plugin-zip",
"plugin-zip": "composer prepare-dist && wp-scripts plugin-zip",
"lint:js": "wp-scripts lint-js assets/src tests/js --no-error-on-unmatched-pattern",
"lint:style": "wp-scripts lint-style \"assets/src/**/*.{css,scss}\" --allow-empty-input"{{#if has_webpack_build}},
"build": "wp-scripts build --webpack-src-dir=assets/src --output-path=assets/build",
Expand Down
15 changes: 15 additions & 0 deletions templates/readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ License URI: https://www.gnu.org/licenses/gpl-2.0.html
1. Upload the plugin files to the `/wp-content/plugins/{{SLUG}}` directory, or install the plugin through the WordPress plugins screen directly.
2. Activate the plugin through the 'Plugins' screen in WordPress.

{{#if has_webpack_build}}
== Development ==

The JavaScript in this plugin is compiled. The complete, human-readable source
for every built asset lives in the `assets/src/` directory of the project's
public repository, alongside the build tooling (package.json, webpack config)
used to produce the files shipped in this package:

{{#if has_author_uri}}
{{AUTHOR_URI}}
{{else}}
(add your public repository URL here)
{{/if}}

{{/if}}
== Frequently Asked Questions ==

= How do I configure this plugin? =
Expand Down
7 changes: 4 additions & 3 deletions tests/generator.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -978,9 +978,10 @@ test('pure-PHP scaffold gets a packaging-only package.json — no build pipeline
// B6.14: package.json always ships for `npm run plugin-zip` + JS/CSS lint...
const pkg = JSON.parse(fs.readFileSync(path.join(outDir, 'package.json'), 'utf8'));
assert.equal(pkg.private, true);
assert.equal(pkg.scripts['plugin-zip'], 'wp-scripts plugin-zip');
assert.equal(pkg.scripts['plugin-zip'], 'composer prepare-dist && wp-scripts plugin-zip');
assert.ok(pkg.scripts['lint:js'] && pkg.scripts['lint:style']);
assert.ok(Array.isArray(pkg.files) && pkg.files.includes('vendor') && pkg.files.includes('assets/src'), 'B6.14a: vendor/ and assets/src/ ship in the zip');
assert.ok(Array.isArray(pkg.files) && pkg.files.includes('vendor'), 'B6.14a: vendor/ ships in the zip');
assert.ok(!pkg.files.includes('assets/src'), 'B6.14a: unbuilt assets/src/ does not ship');
// ...but nothing build-pipeline-ish.
assert.equal(pkg.scripts.build, undefined);
assert.equal(pkg.scripts.start, undefined);
Expand Down Expand Up @@ -1337,7 +1338,7 @@ test('WooCommerce granular sub-modules: pure-PHP (e.g. woo:shipping + woo:email)

const pkg = JSON.parse(fs.readFileSync(path.join(outDir, 'package.json'), 'utf8'));
assert.equal(pkg.scripts.build, undefined, 'pure-PHP WooCommerce gets no build script');
assert.equal(pkg.scripts['plugin-zip'], 'wp-scripts plugin-zip', 'but still the packaging script');
assert.equal(pkg.scripts['plugin-zip'], 'composer prepare-dist && wp-scripts plugin-zip', 'but still the packaging script');
assert.ok(!fs.existsSync(path.join(outDir, 'webpack.config.js')), 'pure-PHP WooCommerce must not emit webpack.config.js');
assert.ok(!fs.existsSync(path.join(outDir, 'src/Woo/Gateways/Gateway.php')), 'unselected sub-module should not exist');

Expand Down
Loading