From 42d93a9fe5578192e7e016a5b22d3a15dba60718 Mon Sep 17 00:00:00 2001 From: Akshat Date: Wed, 19 Aug 2026 13:56:34 +0530 Subject: [PATCH 1/2] fix: resolve 6 audit findings (2 P0, 1 P1, 2 P2, 1 P3) - .gitattributes (* text=auto eol=lf) + full renormalize: forces LF for every tracked text file regardless of the committing machine's core.autocrlf. Root cause of the P0 where mixed CRLF/LF within a single generated file corrupted PHP_CodeSniffer's tokenizer and produced up to 184 cascading false errors on `composer lint` in the all-modules scaffold. Verified: 0 errors across all 4 scaffold variants after this fix (was 11/184/4/7). - index.js: symlink-safe main-guard (P0). npm/npx install the CLI behind a symlink on macOS/Linux; process.argv[1] was the symlink path while __filename was already realpath-resolved, so the guard's raw-path comparison never matched and main() silently never ran. Now resolves process.argv[1] via fs.realpathSync before comparing. - index.js: validatePrefix / suggestPrefix minimum raised 2 to 4 chars (P1). WPCS's PrefixAllGlobals.ShortPrefixPassed sniff flags prefixes under 4 characters as a collision risk (empirically verified: 3-char prefix still triggers it, 4-char does not -- corrects an earlier assumption of 3). templates/phpcs.xml: removed the severity-0 override that was suppressing that exact diagnostic, so short prefixes fail loudly instead of silently producing bogus PrefixAllGlobals.NonPrefixed*Found errors. suggestPrefix's auto-derived initials (e.g. "My Plugin" -> "mp") are now padded out to stay >= 4 chars so the interactive default keeps working. - index.js: validateModules rejects unknown --modules values instead of silently dropping them (P2). Module list hoisted to a shared MODULE_DEFINITIONS/VALID_MODULES constant reused by the interactive choices and the new validator, so they can't drift apart. - index.js: validateEmail now checks a practical email shape instead of just requiring an "@" character -- "@@@@" no longer passes (P2). - templates/composer.json: package "name" derives from the slugified author name (falls back to literal "vendor") instead of a hardcoded "vendor/{{SLUG}}" that looked like a forgotten placeholder (P3). tests/generator.test.js: updated assertions for the new 4-char prefix floor and added regression coverage for validateModules, validateEmail, and the composer.json vendor-name derivation (both the author-derived and empty-author-fallback cases). Full suite: 24/24 passing. Verified against all 4 scaffold variants (minimal / all-modules+react / react-only / partial-modules): composer install/validate --strict/lint/test all clean, php -l clean, npm test clean. Version left at 1.0.1 -- bump and publish is a separate step. --- .gitattributes | 6 + README.md | 122 +-- index.js | 1858 ++++++++++++++++++++------------------- package-lock.json | 100 +-- package.json | 98 +-- templates/composer.json | 2 +- templates/phpcs.xml | 4 - tests/generator.test.js | 72 +- 8 files changed, 1185 insertions(+), 1077 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..b498ac5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +# Force LF line endings for all text files regardless of the committing +# machine's core.autocrlf setting. This repo is published to npm verbatim +# (index.js + templates/**) โ€” CRLF sneaking into a commit corrupts generated +# scaffolds (mixed CRLF/LF within a single file breaks PHP_CodeSniffer's +# tokenizer) and diverges from what's checked into GitHub. +* text=auto eol=lf diff --git a/README.md b/README.md index c955f47..5237bb3 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,61 @@ -# create-wp-plugin - -Interactive scaffold generator for modern, production-ready WordPress plugins โ€” built with PSR-4 autoloading, WPCS/VIP coding standards, PHPUnit unit tests, WP-CLI command integration, and optional React asset build pipeline (`@wordpress/scripts`). - -## Usage - -### Quick Start via NPX -Run directly without installing: -```bash -npx create-wp-plugin-cli -``` - -> Published on npm as [`create-wp-plugin-cli`](https://www.npmjs.com/package/create-wp-plugin-cli) (the `create-wp-plugin` name was already taken). You can still run the latest `main` branch directly from GitHub with `npx github:akshat009/create-wp-plugin-cli`. - -### Local Usage -```bash -node index.js -``` - -### CLI Flags (Non-Interactive Mode) -```bash -node index.js --yes --name "My Plugin" --prefix myp --namespace MyPlugin --out ./my-plugin -``` - -Available flags: `--help`, `--version`, `--yes`, `--name`, `--slug`, `--namespace`, `--prefix`, `--author`, `--email`, `--author-uri`, `--description`, `--min-php`, `--out`, `--modules`, `--react`, `--no-react`. - -`--modules` accepts a comma-separated list of: `admin_settings`, `shortcode`, `rest_api`, `ajax_handler`, `cpt_taxonomy`, `cron`, `elementor_widget`, `woocommerce_hooks`, `interactivity`. - -## After Generating - -After running the generator, perform the following steps to initialize your project: - -```bash -cd -composer install # installs PHPCS/WPCS/VIP rulesets, PHPUnit (required - # before composer lint / composer test will work) -composer lint -composer test -git init && git add -A && git commit -m "scaffold" -``` - -> **Note:** `composer install` may prompt to allow the `dealerdirect/phpcodesniffer-composer-installer` plugin โ€” answer **yes**, as it registers the WPCS/VIP rulesets with PHPCS. You may also see a message *"No composer.lock file present"* on the first run; this is completely normal and Composer will automatically create the lock file. - -## Features -- โšก **PSR-4 Autoloading**: Clean `src/` directory layout with automatic fallback. -- ๐ŸŽจ **WordPress Coding Standards**: Full WPCS, Docs, VIP Go, and PHPCompatibilityWP integration (`composer lint`). -- ๐Ÿงช **PHPUnit & Brain Monkey**: Zero-WordPress-install unit testing suite (`composer test`). -- ๐Ÿ’ป **WP-CLI Commands**: Built-in `wp status` and `wp cache clear` handlers. -- โš›๏ธ **React Admin App Build Pipeline**: Built-in `@wordpress/scripts` workflow that mounts a real interactive React app into your plugin's own wp-admin screen (not shipped to frontend visitors). -- โšก **Frontend Interactivity Module**: WordPress's native Interactivity API (directive-based, WP 6.5+) for lightweight, actually-interactive frontend markup without shipping a React runtime to visitors. -- ๐Ÿ“ฆ **Modular Architecture**: Toggleable scaffolding for Admin Settings Page, Shortcodes, REST API, AJAX, CPT + Taxonomies, Cron Jobs, Elementor Widgets, WooCommerce, and Frontend Interactivity. -- ๐Ÿ›’ **Full WooCommerce Integration**: Payment gateway (classic checkout **and** WooCommerce Blocks / block-based checkout), shipping method, custom order email (with its own template files), custom product type, HPOS compatibility โ€” all real, working, PHPCS-clean skeletons, not stubs you have to wire up yourself. -- ๐Ÿ”Œ **Service Composition**: Extensible `{{PREFIX}}_services` filter to inject third-party `Registrable` services. -- ๐ŸŽจ **Elementor Auto-Discovery**: Automatic discovery of concrete widgets in `src/Widgets/` with convention-based CSS/JS registration (`{{PREFIX}}-widget-slug`). -- ๐Ÿ› ๏ธ **VS Code Tooling**: Code snippets (`wpelwidget`), PHPCS integration (`valeryanm.vscode-phpsab`), EditorConfig support, and WordPress stubs via Intelephense. -- โš™๏ธ **GitHub Actions CI**: Preconfigured workflow for automated PHPCS linting and PHP version matrix testing. - -> **Note:** To scaffold Gutenberg blocks, use `npx @wordpress/create-block`. - -## License -GPL-2.0-or-later +# create-wp-plugin + +Interactive scaffold generator for modern, production-ready WordPress plugins โ€” built with PSR-4 autoloading, WPCS/VIP coding standards, PHPUnit unit tests, WP-CLI command integration, and optional React asset build pipeline (`@wordpress/scripts`). + +## Usage + +### Quick Start via NPX +Run directly without installing: +```bash +npx create-wp-plugin-cli +``` + +> Published on npm as [`create-wp-plugin-cli`](https://www.npmjs.com/package/create-wp-plugin-cli) (the `create-wp-plugin` name was already taken). You can still run the latest `main` branch directly from GitHub with `npx github:akshat009/create-wp-plugin-cli`. + +### Local Usage +```bash +node index.js +``` + +### CLI Flags (Non-Interactive Mode) +```bash +node index.js --yes --name "My Plugin" --prefix myp --namespace MyPlugin --out ./my-plugin +``` + +Available flags: `--help`, `--version`, `--yes`, `--name`, `--slug`, `--namespace`, `--prefix`, `--author`, `--email`, `--author-uri`, `--description`, `--min-php`, `--out`, `--modules`, `--react`, `--no-react`. + +`--modules` accepts a comma-separated list of: `admin_settings`, `shortcode`, `rest_api`, `ajax_handler`, `cpt_taxonomy`, `cron`, `elementor_widget`, `woocommerce_hooks`, `interactivity`. + +## After Generating + +After running the generator, perform the following steps to initialize your project: + +```bash +cd +composer install # installs PHPCS/WPCS/VIP rulesets, PHPUnit (required + # before composer lint / composer test will work) +composer lint +composer test +git init && git add -A && git commit -m "scaffold" +``` + +> **Note:** `composer install` may prompt to allow the `dealerdirect/phpcodesniffer-composer-installer` plugin โ€” answer **yes**, as it registers the WPCS/VIP rulesets with PHPCS. You may also see a message *"No composer.lock file present"* on the first run; this is completely normal and Composer will automatically create the lock file. + +## Features +- โšก **PSR-4 Autoloading**: Clean `src/` directory layout with automatic fallback. +- ๐ŸŽจ **WordPress Coding Standards**: Full WPCS, Docs, VIP Go, and PHPCompatibilityWP integration (`composer lint`). +- ๐Ÿงช **PHPUnit & Brain Monkey**: Zero-WordPress-install unit testing suite (`composer test`). +- ๐Ÿ’ป **WP-CLI Commands**: Built-in `wp status` and `wp cache clear` handlers. +- โš›๏ธ **React Admin App Build Pipeline**: Built-in `@wordpress/scripts` workflow that mounts a real interactive React app into your plugin's own wp-admin screen (not shipped to frontend visitors). +- โšก **Frontend Interactivity Module**: WordPress's native Interactivity API (directive-based, WP 6.5+) for lightweight, actually-interactive frontend markup without shipping a React runtime to visitors. +- ๐Ÿ“ฆ **Modular Architecture**: Toggleable scaffolding for Admin Settings Page, Shortcodes, REST API, AJAX, CPT + Taxonomies, Cron Jobs, Elementor Widgets, WooCommerce, and Frontend Interactivity. +- ๐Ÿ›’ **Full WooCommerce Integration**: Payment gateway (classic checkout **and** WooCommerce Blocks / block-based checkout), shipping method, custom order email (with its own template files), custom product type, HPOS compatibility โ€” all real, working, PHPCS-clean skeletons, not stubs you have to wire up yourself. +- ๐Ÿ”Œ **Service Composition**: Extensible `{{PREFIX}}_services` filter to inject third-party `Registrable` services. +- ๐ŸŽจ **Elementor Auto-Discovery**: Automatic discovery of concrete widgets in `src/Widgets/` with convention-based CSS/JS registration (`{{PREFIX}}-widget-slug`). +- ๐Ÿ› ๏ธ **VS Code Tooling**: Code snippets (`wpelwidget`), PHPCS integration (`valeryanm.vscode-phpsab`), EditorConfig support, and WordPress stubs via Intelephense. +- โš™๏ธ **GitHub Actions CI**: Preconfigured workflow for automated PHPCS linting and PHP version matrix testing. + +> **Note:** To scaffold Gutenberg blocks, use `npx @wordpress/create-block`. + +## License +GPL-2.0-or-later diff --git a/index.js b/index.js index ef3847f..3cf650d 100644 --- a/index.js +++ b/index.js @@ -1,907 +1,951 @@ -#!/usr/bin/env node - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import { parseArgs } from 'node:util'; -import prompts from 'prompts'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -export function slugify(text) { - if (!text) return ''; - return text - .toString() - .toLowerCase() - .trim() - .replace(/_/g, '-') - .replace(/\s+/g, '-') - .replace(/[^a-z0-9\-]+/g, '') - .replace(/\-\-+/g, '-') - .replace(/^-+|-+$/g, ''); -} - -export function suggestNamespace(name) { - if (!name || typeof name !== 'string') return 'MyPlugin'; - const fillers = new Set(['for', 'the', 'and', 'of', 'to', 'a', 'in', 'on', 'with', 'by', 'an', 'or', 'at', 'from', 'is']); - const rawWords = name.trim().split(/[\s-_]+/).filter(Boolean); - if (rawWords.length === 0) return 'MyPlugin'; - const filtered = rawWords.filter(w => !fillers.has(w.toLowerCase())); - const words = filtered.length > 0 ? filtered : rawWords; - const studly = words - .map(w => w.replace(/[^A-Za-z0-9_]/g, '')) - .filter(Boolean) - .map(w => w.charAt(0).toUpperCase() + w.slice(1)) - .join(''); - return studly || 'MyPlugin'; -} - -export function suggestPrefix(name) { - if (!name) return 'myplug'; - const fillers = new Set(['for', 'the', 'and', 'of', 'to', 'a', 'in', 'on', 'with', 'by', 'an', 'or', 'at', 'from', 'is']); - const words = name.trim().split(/[\s-_]+/).filter(Boolean); - if (words.length === 0) return 'myplug'; - - const filtered = words.filter(w => !fillers.has(w.toLowerCase())); - const targetWords = filtered.length > 0 ? filtered : words; - if (targetWords.length === 1) return targetWords[0].toLowerCase(); - - return targetWords.map(w => w.charAt(0).toLowerCase()).join(''); -} - -export function validateName(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'Plugin name is required.'; - } - return true; -} - -export function validateSlug(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'Plugin slug is required.'; - } - const processed = slugify(val); - if (processed !== val || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(val)) { - return 'Plugin slug must be lowercase alphanumeric characters separated by single hyphens (e.g. my-plugin).'; - } - return true; -} - -const RESERVED_PREFIXES = new Set(['wp', 'wordpress', 'php', '__']); - -export function validatePrefix(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'Function/constant prefix is required.'; - } - if (val.includes('-')) { - return 'Prefix cannot contain hyphens because hyphens are invalid in PHP function names and constants.'; - } - if (val.length < 2 || val.length > 20) { - return 'Prefix must be between 2 and 20 characters.'; - } - if (!/^[a-z][a-z0-9_]*$/.test(val)) { - return 'Prefix must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.'; - } - if (RESERVED_PREFIXES.has(val.toLowerCase())) { - return 'Prefix cannot be a reserved word ("wp", "wordpress", "php") โ€” WordPress.org plugin review and WPCS PrefixAllGlobals will reject it.'; - } - return true; -} - -export function validateNamespace(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'PHP namespace is required.'; - } - if (!/^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*$/.test(val)) { - return 'Namespace must be one or more \\-separated segments (e.g. MyPlugin or Vendor\\MyPlugin), each starting with a letter or underscore and containing only ASCII letters, numbers, and underscores.'; - } - return true; -} - -export function validateEmail(val) { - if (val === undefined || val === null || val === '') return true; - if (typeof val === 'string' && val.trim().length > 0) { - if (!val.includes('@')) { - return 'Author email must contain "@".'; - } - } - return true; -} - -export function validateMinPhp(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'Minimum PHP version is required.'; - } - if (!/^\d+\.\d+$/.test(val.trim())) { - return 'Minimum PHP version must be in format X.Y (e.g. 8.0).'; - } - return true; -} - -export function validateOutputDir(val) { - if (!val || typeof val !== 'string' || val.trim().length === 0) { - return 'Output directory is required.'; - } - // Deliberately unrestricted beyond "non-empty": scaffolding into a sibling - // directory (e.g. --out ../plugins/my-plugin) is the common WP dev workflow. - // The real safety net is runGenerator()'s non-empty-directory guard below. - return true; -} - -export function validateAll(answers) { - const checks = [ - { field: 'name', result: validateName(answers.name) }, - { field: 'slug', result: validateSlug(answers.slug) }, - { field: 'namespace', result: validateNamespace(answers.namespace) }, - { field: 'prefix', result: validatePrefix(answers.prefix) }, - { field: 'email', result: validateEmail(answers.authorEmail) }, - { field: 'minPhp', result: validateMinPhp(answers.minPhp) }, - { field: 'outputDir', result: validateOutputDir(answers.outputDir) } - ]; - - for (const check of checks) { - if (check.result !== true) { - return check.result; - } - } - return true; -} - -function parseCLIArgs() { - const options = { - help: { type: 'boolean', short: 'h' }, - version: { type: 'boolean', short: 'v' }, - yes: { type: 'boolean', short: 'y' }, - name: { type: 'string' }, - slug: { type: 'string' }, - namespace: { type: 'string' }, - prefix: { type: 'string' }, - author: { type: 'string' }, - email: { type: 'string' }, - 'author-uri': { type: 'string' }, - description: { type: 'string' }, - 'min-php': { type: 'string' }, - out: { type: 'string' }, - modules: { type: 'string' }, - react: { type: 'boolean' }, - 'no-react': { type: 'boolean' } - }; - - try { - const parsed = parseArgs({ options, allowPositionals: true }); - return parsed.values; - } catch (err) { - console.error(`โŒ Invalid argument: ${err.message}`); - process.exit(1); - } -} - -function showHelp() { - console.log(` -Usage: create-wp-plugin-cli [options] - -Options: - -h, --help Show help text - -v, --version Show version number - -y, --yes Skip interactive prompts and generate plugin non-interactively - --name Plugin name - --slug Plugin slug - --namespace PHP namespace - --prefix Function/constant prefix - --author Author name - --email Author email - --author-uri Author URI / GitHub URL - --description Plugin description - --min-php Minimum PHP version - --out Output directory - --modules Comma-separated list of modules (admin_settings,shortcode,rest_api,ajax_handler,cpt_taxonomy,cron,elementor_widget,woocommerce_hooks,interactivity) - --react Include React admin app build pipeline (wp-admin only) - --no-react Do not include React admin app build pipeline -`); -} - -function showVersion() { - const pkgPath = path.join(__dirname, 'package.json'); - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - console.log(pkg.version); -} - -function parseModules(modulesStr) { - if (modulesStr === undefined || modulesStr === null) return []; - if (modulesStr.trim() === '') return []; - return modulesStr.split(',').map(m => m.trim()).filter(Boolean); -} - -async function main() { - const flags = parseCLIArgs(); - - if (flags.help) { - showHelp(); - process.exit(0); - } - - if (flags.version) { - showVersion(); - process.exit(0); - } - - let answers; - - if (flags.yes) { - if (!flags.name) { - console.error('โŒ Error: --name is required when --yes is set.'); - process.exit(1); - } - if (!flags.out) { - console.error('โŒ Error: --out is required when --yes is set.'); - process.exit(1); - } - - const name = flags.name; - const slug = flags.slug || slugify(name); - const namespace = flags.namespace || suggestNamespace(name); - const prefix = flags.prefix || suggestPrefix(name); - const authorName = flags.author || ''; - const authorEmail = flags.email || ''; - const authorUri = flags['author-uri'] || ''; - const description = flags.description || 'A powerful modern WordPress plugin scaffold.'; - const minPhp = flags['min-php'] || '8.0'; - const useReact = Boolean(flags.react); - const modules = flags.modules !== undefined ? parseModules(flags.modules) : []; - const outputDir = flags.out; - - answers = { - name, - slug, - namespace, - prefix, - authorName, - authorEmail, - authorUri, - description, - minPhp, - useReact, - modules, - outputDir - }; - - const valid = validateAll(answers); - if (valid !== true) { - console.error(`โŒ Validation error: ${valid}`); - process.exit(1); - } - } else { - console.log('\n๐Ÿš€ Welcome to create-wp-plugin-cli scaffold generator!\n'); - - const initialModules = flags.modules !== undefined ? parseModules(flags.modules) : []; - - const choices = [ - { title: 'admin settings page', value: 'admin_settings' }, - { title: 'shortcode', value: 'shortcode' }, - { title: 'REST API', value: 'rest_api' }, - { title: 'AJAX handler', value: 'ajax_handler' }, - { title: 'CPT + taxonomy', value: 'cpt_taxonomy' }, - { title: 'cron', value: 'cron' }, - { title: 'Elementor widget base', value: 'elementor_widget' }, - { title: 'WooCommerce hooks', value: 'woocommerce_hooks' }, - { title: 'Frontend Interactivity (WordPress Interactivity API)', value: 'interactivity' } - ].map(c => ({ - ...c, - selected: initialModules.includes(c.value) - })); - - const questions = [ - { - type: 'text', - name: 'name', - message: '1. Plugin name:', - initial: flags.name || '', - validate: validateName - }, - { - type: 'text', - name: 'slug', - message: '2. Plugin slug:', - initial: flags.slug || ((prev, values) => slugify(values.name)), - validate: validateSlug - }, - { - type: 'text', - name: 'namespace', - message: '3. PHP namespace:', - initial: flags.namespace || ((prev, values) => suggestNamespace(values.name)), - validate: validateNamespace - }, - { - type: 'text', - name: 'prefix', - message: '4. Function/constant prefix:', - initial: flags.prefix || ((prev, values) => suggestPrefix(values.name)), - validate: validatePrefix - }, - { - type: 'text', - name: 'authorName', - message: '5. Author name:', - initial: flags.author || '' - }, - { - type: 'text', - name: 'authorEmail', - message: '6. Author email:', - initial: flags.email || '', - validate: validateEmail - }, - { - type: 'text', - name: 'authorUri', - message: '7. Author URI / GitHub URL:', - initial: flags['author-uri'] || '' - }, - { - type: 'text', - name: 'description', - message: '8. Description (one line):', - initial: flags.description || 'A powerful modern WordPress plugin scaffold.' - }, - { - type: 'text', - name: 'minPhp', - message: '9. Minimum PHP version:', - initial: flags['min-php'] || '8.0', - validate: validateMinPhp - }, - { - type: 'confirm', - name: 'useReact', - message: '10. Include React admin app build pipeline (@wordpress/scripts, wp-admin only)?', - initial: Boolean(flags.react) - }, - { - type: 'multiselect', - name: 'modules', - message: '11. Modules to include (multi-select, space to toggle):', - choices, - hint: '- Space to select. Return to submit' - }, - { - type: 'text', - name: 'outputDir', - message: '12. Output directory:', - initial: flags.out || ((prev, values) => `./${values.slug}`), - validate: validateOutputDir - } - ]; - - answers = await prompts(questions, { - onCancel: () => { - console.log('\nOperation cancelled.'); - process.exit(1); - } - }); - - if (!answers.name) { - console.log('\nOperation cancelled.'); - process.exit(1); - } - - console.log('\nSummary:'); - console.log(` Name: ${answers.name}`); - console.log(` Slug: ${answers.slug}`); - console.log(` Namespace: ${answers.namespace}`); - console.log(` Prefix: ${answers.prefix}`); - console.log(` Author: ${answers.authorName || '(none)'}`); - console.log(''); - - const confirm = await prompts({ - type: 'confirm', - name: 'value', - message: 'Proceed with these values?', - initial: true - }, { - onCancel: () => { - console.log('\nOperation cancelled.'); - process.exit(1); - } - }); - - if (!confirm.value) { - console.log('\nOperation cancelled.'); - process.exit(0); - } - } - - try { - runGenerator(answers); - } catch (err) { - console.error(`\nโŒ Error: ${err.message}`); - process.exit(1); - } -} - -export function runGenerator(answers) { - answers.outputDir = answers.outputDir || answers.out; - const outputDir = answers.outputDir; - const targetDir = path.resolve(process.cwd(), outputDir); - - if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) { - throw new Error(`Directory "${outputDir}" already exists and is not empty.`); - } - - fs.mkdirSync(targetDir, { recursive: true }); - - const selectedModules = answers.modules || []; - const hasInteractivity = selectedModules.includes('interactivity'); - const hasWoo = selectedModules.includes('woocommerce_hooks'); - - const requiredPlugins = []; - if (selectedModules.includes('elementor_widget')) requiredPlugins.push('elementor'); - if (hasWoo) requiredPlugins.push('woocommerce'); - - let pluginHeaderExtra = ''; - if (requiredPlugins.length > 0) { - pluginHeaderExtra += ` * Requires Plugins: ${requiredPlugins.join(', ')}\n`; - } - if (selectedModules.includes('elementor_widget')) { - pluginHeaderExtra += ' * Elementor tested up to: 3.27.0\n * Elementor Pro tested up to: 3.27.0\n'; - } - - // The Interactivity API (wp_interactivity_state, Script Modules) requires WP 6.5+. - // The Cart Summary block's block.json "render" field requires WP 6.4+. - const requiredWpVersion = hasInteractivity ? '6.5' : (hasWoo ? '6.4' : '6.0'); - - // php-stubs/woocommerce-stubs gives PHPCS/Intelephense real class definitions for - // WC_Payment_Gateway, WC_Shipping_Method, WC_Email, WC_Product, etc. - const composerExtraRequireDev = hasWoo ? ',\n\t\t"php-stubs/woocommerce-stubs": "^9.0"' : ''; - const vscodeExtraStubPath = hasWoo ? ',\n\t\t"vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php"' : ''; - - const woocommerceHpos = hasWoo - ? `add_action( - 'before_woocommerce_init', - function () { - if ( class_exists( \\Automattic\\WooCommerce\\Utilities\\FeaturesUtil::class ) ) { - \\Automattic\\WooCommerce\\Utilities\\FeaturesUtil::declare_compatibility( 'custom_order_tables', ${answers.prefix.toUpperCase()}_FILE, true ); - } - } -);\n\n` - : ''; - - const replacements = { - '{{PLUGIN_NAME}}': answers.name, - '{{SLUG}}': answers.slug, - '{{NS}}': answers.namespace, - '{{NS_ESCAPED}}': answers.namespace.replace(/\\/g, '\\\\'), - '{{PREFIX}}': answers.prefix.toLowerCase(), - '{{PREFIX_UPPER}}': answers.prefix.toUpperCase(), - '{{AUTHOR}}': answers.authorName, - '{{AUTHOR_EMAIL}}': answers.authorEmail || 'author@example.com', - '{{AUTHOR_URI}}': answers.authorUri, - '{{DESCRIPTION}}': answers.description, - '{{MIN_PHP}}': answers.minPhp, - '{{REQUIRES_AT_LEAST}}': requiredWpVersion, - '{{VERSION}}': '0.1.0', - '{{YEAR}}': new Date().getFullYear().toString(), - '{{PLUGIN_HEADER_EXTRA}}': pluginHeaderExtra, - '{{WOOCOMMERCE_HPOS}}': woocommerceHpos, - '{{COMPOSER_EXTRA_REQUIRE_DEV}}': composerExtraRequireDev, - '{{VSCODE_EXTRA_STUB_PATH}}': vscodeExtraStubPath - }; - - function processTemplateContent(content) { - let result = content; - for (const [key, val] of Object.entries(replacements)) { - result = result.replaceAll(key, () => val); - } - return result; - } - - function writeTemplateFile(srcPath, destRelativePath) { - const raw = fs.readFileSync(srcPath, 'utf8'); - const processed = processTemplateContent(raw); - const destPath = path.join(targetDir, destRelativePath); - fs.mkdirSync(path.dirname(destPath), { recursive: true }); - fs.writeFileSync(destPath, processed, 'utf8'); - } - - const templatesDir = path.join(__dirname, 'templates'); - - // Copy standard templates - writeTemplateFile(path.join(templatesDir, 'src/Contracts/Registrable.php'), 'src/Contracts/Registrable.php'); - writeTemplateFile(path.join(templatesDir, 'plugin-main.php'), `${answers.slug}.php`); - writeTemplateFile(path.join(templatesDir, 'composer.json'), 'composer.json'); - writeTemplateFile(path.join(templatesDir, 'phpcs.xml'), 'phpcs.xml'); - writeTemplateFile(path.join(templatesDir, 'src/CLI/Commands.php'), 'src/CLI/Commands.php'); - writeTemplateFile(path.join(templatesDir, 'tests/bootstrap.php'), 'tests/bootstrap.php'); - writeTemplateFile(path.join(templatesDir, 'phpunit.xml.dist'), 'phpunit.xml.dist'); - writeTemplateFile(path.join(templatesDir, 'tests/Unit/Example_Test.php'), 'tests/Unit/Example_Test.php'); - writeTemplateFile(path.join(templatesDir, 'gitignore.tpl'), '.gitignore'); - writeTemplateFile(path.join(templatesDir, 'editorconfig.tpl'), '.editorconfig'); - writeTemplateFile(path.join(templatesDir, 'distignore.tpl'), '.distignore'); - writeTemplateFile(path.join(templatesDir, 'assets/css/main.css'), 'assets/css/main.css'); - writeTemplateFile(path.join(templatesDir, 'assets/js/main.js'), 'assets/js/main.js'); - writeTemplateFile(path.join(templatesDir, 'readme.txt'), 'readme.txt'); - writeTemplateFile(path.join(templatesDir, 'languages/.gitkeep'), 'languages/.gitkeep'); - writeTemplateFile(path.join(templatesDir, '.vscode/php.code-snippets'), '.vscode/php.code-snippets'); - writeTemplateFile(path.join(templatesDir, '.vscode/extensions.json'), '.vscode/extensions.json'); - writeTemplateFile(path.join(templatesDir, '.vscode/settings.json'), '.vscode/settings.json'); - - // Selected modules mapping - const moduleRegistrations = []; - const bootHooks = []; - - if (selectedModules.includes('admin_settings')) { - let settingsContent = fs.readFileSync(path.join(templatesDir, 'src/Admin/Settings_Page.php'), 'utf8'); - const reactAdminRoot = answers.useReact ? '\t\t\t
\n' : ''; - settingsContent = settingsContent.replace('{{REACT_ADMIN_ROOT}}', () => reactAdminRoot); - settingsContent = processTemplateContent(settingsContent); - const settingsDest = path.join(targetDir, 'src/Admin/Settings_Page.php'); - fs.mkdirSync(path.dirname(settingsDest), { recursive: true }); - fs.writeFileSync(settingsDest, settingsContent, 'utf8'); - moduleRegistrations.push('\n\t\t$services[\'admin_settings\'] = new Admin\\Settings_Page();'); - } - if (selectedModules.includes('shortcode')) { - writeTemplateFile(path.join(templatesDir, 'src/Frontend/Shortcode.php'), 'src/Frontend/Shortcode.php'); - moduleRegistrations.push('\n\t\t$services[\'shortcode\'] = new Frontend\\Shortcode();'); - } - if (selectedModules.includes('rest_api')) { - writeTemplateFile(path.join(templatesDir, 'src/Rest/Rest_Controller.php'), 'src/Rest/Rest_Controller.php'); - moduleRegistrations.push('\n\t\t$services[\'rest\'] = new Rest\\Rest_Controller();'); - } - if (selectedModules.includes('ajax_handler')) { - writeTemplateFile(path.join(templatesDir, 'src/Ajax/Ajax_Handler.php'), 'src/Ajax/Ajax_Handler.php'); - moduleRegistrations.push('\n\t\t$services[\'ajax\'] = new Ajax\\Ajax_Handler();'); - } - if (selectedModules.includes('cpt_taxonomy')) { - writeTemplateFile(path.join(templatesDir, 'src/PostTypes/Post_Types.php'), 'src/PostTypes/Post_Types.php'); - moduleRegistrations.push('\n\t\t$services[\'post_types\'] = new PostTypes\\Post_Types();'); - } - if (selectedModules.includes('cron')) { - writeTemplateFile(path.join(templatesDir, 'src/Cron/Scheduler.php'), 'src/Cron/Scheduler.php'); - moduleRegistrations.push('\n\t\t$services[\'cron\'] = new Cron\\Scheduler();'); - } - if (selectedModules.includes('elementor_widget')) { - writeTemplateFile(path.join(templatesDir, '.vscode/php-elementor.code-snippets'), '.vscode/php-elementor.code-snippets'); - writeTemplateFile(path.join(templatesDir, 'src/Elementor/Dependency_Notice.php'), 'src/Elementor/Dependency_Notice.php'); - writeTemplateFile(path.join(templatesDir, 'src/Widgets/Sample_Widget.php'), 'src/Widgets/Sample_Widget.php'); - writeTemplateFile(path.join(templatesDir, 'assets/css/widgets/sample-widget.css'), 'assets/css/widgets/sample-widget.css'); - writeTemplateFile(path.join(templatesDir, 'assets/js/widgets/sample-widget.js'), 'assets/js/widgets/sample-widget.js'); - moduleRegistrations.push('\n\t\t$services[\'elementor_notice\'] = new Elementor\\Dependency_Notice();'); - // Hook registration is a boot()-time side effect, not a service build step โ€” it must run - // even when get_instance() is called with an injected $services array that skips build_services(). - bootHooks.push('\t\tadd_action( \'wp_enqueue_scripts\', array( $this, \'register_widget_assets\' ) );'); - bootHooks.push('\t\tadd_action( \'elementor/editor/after_enqueue_styles\', array( $this, \'register_widget_assets\' ) );'); - bootHooks.push('\t\tadd_action( \'elementor/widgets/register\', array( $this, \'register_widgets\' ) );'); - } - if (selectedModules.includes('woocommerce_hooks')) { - writeTemplateFile(path.join(templatesDir, 'src/Woo/Woo_Hooks.php'), 'src/Woo/Woo_Hooks.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Gateways/Gateway.php'), 'src/Woo/Gateways/Gateway.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Gateways/Blocks_Payment_Method_Type.php'), 'src/Woo/Gateways/Blocks_Payment_Method_Type.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Shipping/Shipping_Method.php'), 'src/Woo/Shipping/Shipping_Method.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Emails/Custom_Email.php'), 'src/Woo/Emails/Custom_Email.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Products/Custom_Product.php'), 'src/Woo/Products/Custom_Product.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Blocks/Integration.php'), 'src/Woo/Blocks/Integration.php'); - writeTemplateFile(path.join(templatesDir, 'src/Woo/Blocks/Cart_Summary_Block.php'), 'src/Woo/Blocks/Cart_Summary_Block.php'); - writeTemplateFile(path.join(templatesDir, 'woo-email-templates/emails/custom-email.php'), `templates/emails/${answers.prefix.toLowerCase()}-custom-email.php`); - writeTemplateFile(path.join(templatesDir, 'woo-email-templates/emails/plain/custom-email.php'), `templates/emails/plain/${answers.prefix.toLowerCase()}-custom-email.php`); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/wc-gateway-block.js'), 'assets/src/wc-gateway-block.js'); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks-integration.js'), 'assets/src/blocks-integration.js'); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/block.json'), 'assets/src/blocks/cart-summary/block.json'); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/index.js'), 'assets/src/blocks/cart-summary/index.js'); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/render.php'), 'assets/src/blocks/cart-summary/render.php'); - moduleRegistrations.push('\n\t\t$services[\'woo\'] = new Woo\\Woo_Hooks();'); - } - if (selectedModules.includes('interactivity')) { - writeTemplateFile(path.join(templatesDir, 'src/Frontend/Interactivity.php'), 'src/Frontend/Interactivity.php'); - writeTemplateFile(path.join(templatesDir, 'react/assets/src/view.js'), 'assets/src/view.js'); - moduleRegistrations.push('\n\t\t$services[\'interactivity\'] = new Frontend\\Interactivity();'); - } - - let elementorWidgetMethods = ''; - if (selectedModules.includes('elementor_widget')) { - elementorWidgetMethods = `\t/** -\t * Get auto-discovered Elementor widget class names (cached via transient unless SCRIPT_DEBUG is active). -\t * -\t * @return array List of widget class names. -\t */ -\tprivate function get_widget_classes(): array { -\t\tif ( ! did_action( 'elementor/loaded' ) ) { -\t\t\treturn array(); -\t\t} - -\t\tif ( ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) { -\t\t\t$cached = get_transient( '{{PREFIX}}_elementor_widgets' ); -\t\t\tif ( is_array( $cached ) ) { -\t\t\t\treturn $cached; -\t\t\t} -\t\t} - -\t\t$widget_classes = array(); -\t\t$files = glob( __DIR__ . '/Widgets/*.php' ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found - -\t\tforeach ( $files as $file ) { -\t\t\t$class_name = __NAMESPACE__ . '\\\\Widgets\\\\' . basename( $file, '.php' ); - -\t\t\tif ( ! class_exists( $class_name ) ) { -\t\t\t\tcontinue; -\t\t\t} - -\t\t\t$reflection = new \\ReflectionClass( $class_name ); - -\t\t\tif ( $reflection->isAbstract() || ! $reflection->isSubclassOf( '\\\\Elementor\\\\Widget_Base' ) ) { -\t\t\t\tcontinue; -\t\t\t} - -\t\t\t$widget_classes[] = $class_name; -\t\t} - -\t\tset_transient( '{{PREFIX}}_elementor_widgets', $widget_classes, DAY_IN_SECONDS ); -\t\treturn $widget_classes; -\t} - -\t/** -\t * Auto-register per-widget stylesheets and scripts derived by convention from widget class names. -\t * -\t * @return void -\t */ -\tpublic function register_widget_assets(): void { -\t\tif ( ! did_action( 'elementor/loaded' ) ) { -\t\t\treturn; -\t\t} - -\t\t$widget_classes = $this->get_widget_classes(); - -\t\tforeach ( $widget_classes as $class_name ) { -\t\t\t$short_name = substr( $class_name, strrpos( $class_name, '\\\\' ) + 1 ); -\t\t\t$slug = strtolower( str_replace( '_', '-', $short_name ) ); -\t\t\t$handle = '{{PREFIX}}-' . $slug; -\t\t\t$css_file = {{PREFIX_UPPER}}_PATH . 'assets/css/widgets/' . $slug . '.css'; -\t\t\t$js_file = {{PREFIX_UPPER}}_PATH . 'assets/js/widgets/' . $slug . '.js'; - -\t\t\tif ( file_exists( $css_file ) ) { -\t\t\t\twp_register_style( -\t\t\t\t\t$handle, -\t\t\t\t\t{{PREFIX_UPPER}}_URL . 'assets/css/widgets/' . $slug . '.css', -\t\t\t\t\tarray(), -\t\t\t\t\t{{PREFIX_UPPER}}_VERSION -\t\t\t\t); -\t\t\t} - -\t\t\tif ( file_exists( $js_file ) ) { -\t\t\t\twp_register_script( -\t\t\t\t\t$handle, -\t\t\t\t\t{{PREFIX_UPPER}}_URL . 'assets/js/widgets/' . $slug . '.js', -\t\t\t\t\tarray( 'jquery' ), -\t\t\t\t\t{{PREFIX_UPPER}}_VERSION, -\t\t\t\t\ttrue -\t\t\t\t); -\t\t\t} -\t\t} -\t} - -\t/** -\t * Auto-register discovered concrete Elementor widget classes. -\t * -\t * @param object $widgets_manager Elementor widgets manager. -\t * @return void -\t */ -\tpublic function register_widgets( $widgets_manager ): void { -\t\tif ( ! did_action( 'elementor/loaded' ) ) { -\t\t\treturn; -\t\t} - -\t\t$widget_classes = $this->get_widget_classes(); - -\t\tforeach ( $widget_classes as $class_name ) { -\t\t\tif ( class_exists( $class_name ) ) { -\t\t\t\t$widgets_manager->register( new $class_name() ); -\t\t\t} -\t\t} -\t}\n\n`; - } - - // React admin app (wp-admin only) + Interactivity API (frontend) + WooCommerce - // Blocks gateway build pipeline. These are independent toggles that share one - // @wordpress/scripts build: - // useReact -> assets/src/index.js (wp-admin React app) - // interactivity mod -> assets/src/view.js (frontend Interactivity API store) - // woocommerce_hooks -> assets/src/wc-gateway-block.js (block checkout payment method) - const needsBuildPipeline = answers.useReact || hasInteractivity || hasWoo; - - let reactAssetsRegistration = ''; - let readmeReactInstall = ''; - let readmeReactScripts = ''; - let ciNodeJob = ''; - - if (answers.useReact) { - writeTemplateFile(path.join(templatesDir, 'react/assets/src/index.js'), 'assets/src/index.js'); - - let assetsContent = fs.readFileSync(path.join(templatesDir, 'src/Admin/Assets.php'), 'utf8'); - const reactAdminHookGuard = selectedModules.includes('admin_settings') - ? '\t\tif ( \'settings_page_{{SLUG}}\' !== $hook_suffix ) {\n\t\t\treturn;\n\t\t}\n\n' - : '\t\t// TODO: narrow this to your plugin\'s own admin screen(s), e.g. compare $hook_suffix.\n'; - assetsContent = assetsContent.replace('{{REACT_ADMIN_HOOK_GUARD}}', () => reactAdminHookGuard); - assetsContent = processTemplateContent(assetsContent); - const assetsDestPath = path.join(targetDir, 'src/Admin/Assets.php'); - fs.mkdirSync(path.dirname(assetsDestPath), { recursive: true }); - fs.writeFileSync(assetsDestPath, assetsContent, 'utf8'); - - reactAssetsRegistration = '\n\t\t$services[\'assets\'] = new Admin\\Assets();\n'; - } - - if (needsBuildPipeline) { - writeTemplateFile(path.join(templatesDir, 'react/package.json'), 'package.json'); - - // wp-scripts only auto-detects a single "src/index.js" entry (or, if any - // block.json exists under the src dir, ONLY the entries it derives from - // block.json files โ€” "src/index.js" is silently dropped in that case). - // Once we ship more than one of: the admin app, the Interactivity API view - // script, the WooCommerce Blocks gateway/integration scripts, or a native - // block (block.json), we must override entry resolution via webpack.config.js - // โ€” wp-scripts picks this file up automatically if present at the project root. - // - // IMPORTANT: @wordpress/scripts assigns `entry` as a *function* (webpack's - // lazy-entry form) so it can glob for block.json files at build time, not a - // plain object โ€” `{ ...defaultConfig.entry }` silently spreads to `{}` and - // drops every auto-discovered block entry. It must be invoked, not spread. - if (hasInteractivity || hasWoo) { - const entries = []; - if (answers.useReact) entries.push('\t\tindex: \'./assets/src/index.js\','); - if (hasInteractivity) entries.push('\t\tview: \'./assets/src/view.js\','); - if (hasWoo) { - entries.push('\t\t\'wc-gateway-block\': \'./assets/src/wc-gateway-block.js\','); - entries.push('\t\t\'blocks-integration\': \'./assets/src/blocks-integration.js\','); - } - - const webpackConfig = `const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); - -/** - * Merges our explicit entries with wp-scripts' own lazily-computed entry - * function so native blocks (block.json under assets/src/blocks/**) keep - * building automatically alongside them. wp-scripts loads this file - * automatically when present at the project root. - */ -module.exports = { - ...defaultConfig, - entry: () => ( { - ...( typeof defaultConfig.entry === 'function' ? defaultConfig.entry() : defaultConfig.entry ), -${entries.join('\n')} - } ), -}; -`; - fs.writeFileSync(path.join(targetDir, 'webpack.config.js'), webpackConfig, 'utf8'); - } - - readmeReactInstall = '3. Run `npm install` and `npm run build` to compile JS assets.\n > Note: `assets/build` is gitignored and generated during build.'; - readmeReactScripts = '- `npm run build` โ€” Build JS assets for production.\n- `npm run start` โ€” Start JS asset dev server in watch mode.'; - - ciNodeJob = ` - node-build: - name: Build JS Assets - runs-on: ubuntu-latest - steps: - - name: Checkout Code - uses: actions/checkout@v4 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install Node Dependencies - run: npm install - - - name: Build Assets - run: npm run build`; - } - - // Process Plugin.php template with dynamic registrations - let pluginContent = fs.readFileSync(path.join(templatesDir, 'src/Plugin.php'), 'utf8'); - pluginContent = pluginContent.replace('{{REACT_ASSETS_REGISTRATION}}', () => reactAssetsRegistration); - pluginContent = pluginContent.replace('{{MODULE_REGISTRATIONS}}', () => moduleRegistrations.length > 0 ? moduleRegistrations.join('\n') + '\n' : ''); - pluginContent = pluginContent.replace('{{ELEMENTOR_WIDGET_METHODS}}', () => elementorWidgetMethods); - pluginContent = pluginContent.replace('{{BOOT_HOOKS}}', () => bootHooks.length > 0 ? bootHooks.join('\n') + '\n' : ''); - pluginContent = processTemplateContent(pluginContent); - const pluginDestPath = path.join(targetDir, 'src/Plugin.php'); - fs.mkdirSync(path.dirname(pluginDestPath), { recursive: true }); - fs.writeFileSync(pluginDestPath, pluginContent, 'utf8'); - - const allPhpVersions = ['8.0', '8.1', '8.2', '8.3']; - const minPhpNum = parseFloat(answers.minPhp || '8.0'); - let validMatrixVersions = allPhpVersions.filter(v => parseFloat(v) >= minPhpNum); - if (!validMatrixVersions.includes(answers.minPhp)) { - validMatrixVersions.push(answers.minPhp); - } - validMatrixVersions.sort((a, b) => parseFloat(a) - parseFloat(b)); - const ciPhpMatrix = JSON.stringify(validMatrixVersions).replace(/"/g, "'"); - - // Process ci.yml with dynamic node job - let ciContent = fs.readFileSync(path.join(templatesDir, 'github/workflows/ci.yml'), 'utf8'); - ciContent = ciContent.replace('{{CI_PHP_MATRIX}}', () => ciPhpMatrix); - ciContent = ciContent.replace('{{CI_NODE_JOB}}', () => ciNodeJob); - ciContent = processTemplateContent(ciContent); - const ciDestPath = path.join(targetDir, '.github/workflows/ci.yml'); - fs.mkdirSync(path.dirname(ciDestPath), { recursive: true }); - fs.writeFileSync(ciDestPath, ciContent, 'utf8'); - - // Process Activator.php, Deactivator.php, uninstall.php with dynamic bodies - const activatorLines = []; - const deactivatorLines = []; - const uninstallLines = []; - - if (selectedModules.includes('cpt_taxonomy')) { - activatorLines.push('\t\t$post_types = new PostTypes\\Post_Types();'); - activatorLines.push('\t\t$post_types->register_cpt_and_taxonomy();'); - activatorLines.push('\t\tflush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules'); - deactivatorLines.push('\t\tflush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules'); - } - - if (selectedModules.includes('cron')) { - activatorLines.push('\t\tif ( ! wp_next_scheduled( \'{{PREFIX}}_cron_event\' ) ) {'); - activatorLines.push('\t\t\twp_schedule_event( time(), \'hourly\', \'{{PREFIX}}_cron_event\' );'); - activatorLines.push('\t\t}'); - deactivatorLines.push('\t\twp_clear_scheduled_hook( \'{{PREFIX}}_cron_event\' );'); - uninstallLines.push('\twp_clear_scheduled_hook( \'{{PREFIX}}_cron_event\' );'); - } - - if (selectedModules.includes('elementor_widget')) { - activatorLines.push('\t\tdelete_transient( \'{{PREFIX}}_elementor_widgets\' );'); - } - - if (selectedModules.includes('admin_settings')) { - uninstallLines.push('\tdelete_option( \'{{PREFIX}}_option_name\' );'); - } - - const activatorBody = activatorLines.length > 0 ? activatorLines.join('\n') + '\n' : ''; - const deactivatorBody = deactivatorLines.length > 0 ? deactivatorLines.join('\n') + '\n' : ''; - const uninstallBody = uninstallLines.length > 0 ? uninstallLines.join('\n') + '\n' : ''; - - let activatorContent = fs.readFileSync(path.join(templatesDir, 'src/Core/Activator.php'), 'utf8'); - activatorContent = activatorContent.replace('{{ACTIVATOR_BODY}}', () => activatorBody); - activatorContent = processTemplateContent(activatorContent); - const activatorDestPath = path.join(targetDir, 'src/Core/Activator.php'); - fs.mkdirSync(path.dirname(activatorDestPath), { recursive: true }); - fs.writeFileSync(activatorDestPath, activatorContent, 'utf8'); - - let deactivatorContent = fs.readFileSync(path.join(templatesDir, 'src/Core/Deactivator.php'), 'utf8'); - deactivatorContent = deactivatorContent.replace('{{DEACTIVATOR_BODY}}', () => deactivatorBody); - deactivatorContent = processTemplateContent(deactivatorContent); - const deactivatorDestPath = path.join(targetDir, 'src/Core/Deactivator.php'); - fs.mkdirSync(path.dirname(deactivatorDestPath), { recursive: true }); - fs.writeFileSync(deactivatorDestPath, deactivatorContent, 'utf8'); - - let uninstallContent = fs.readFileSync(path.join(templatesDir, 'uninstall.php'), 'utf8'); - uninstallContent = uninstallContent.replace('{{UNINSTALL_BODY}}', () => uninstallBody); - uninstallContent = processTemplateContent(uninstallContent); - const uninstallDestPath = path.join(targetDir, 'uninstall.php'); - fs.writeFileSync(uninstallDestPath, uninstallContent, 'utf8'); - - // Process README.md with dynamic React sections - let readmeContent = fs.readFileSync(path.join(templatesDir, 'README.md'), 'utf8'); - readmeContent = readmeContent.replace('{{README_REACT_INSTALL}}', () => readmeReactInstall); - readmeContent = readmeContent.replace('{{README_REACT_SCRIPTS}}', () => readmeReactScripts); - readmeContent = processTemplateContent(readmeContent); - const readmeDestPath = path.join(targetDir, 'README.md'); - fs.writeFileSync(readmeDestPath, readmeContent, 'utf8'); - - console.log(`\nโœ… Successfully scaffolded plugin "${answers.name}" in ${answers.outputDir}!\n`); - console.log('Next steps:'); - console.log(` cd ${answers.outputDir}`); - console.log(' composer install'); - if (answers.useReact) { - console.log(' npm install'); - console.log(' npm run build'); - } - console.log(' composer lint'); - console.log(' composer test'); - console.log(' git init && git add -A && git commit -m "scaffold"\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'); -} - -if (process.argv[1] && path.resolve(__filename) === path.resolve(process.argv[1])) { - main().catch(err => { - console.error('An error occurred:', err); - process.exit(1); - }); -} +#!/usr/bin/env node + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { parseArgs } from 'node:util'; +import prompts from 'prompts'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const MODULE_DEFINITIONS = [ + { title: 'admin settings page', value: 'admin_settings' }, + { title: 'shortcode', value: 'shortcode' }, + { title: 'REST API', value: 'rest_api' }, + { title: 'AJAX handler', value: 'ajax_handler' }, + { title: 'CPT + taxonomy', value: 'cpt_taxonomy' }, + { title: 'cron', value: 'cron' }, + { title: 'Elementor widget base', value: 'elementor_widget' }, + { title: 'WooCommerce hooks', value: 'woocommerce_hooks' }, + { title: 'Frontend Interactivity (WordPress Interactivity API)', value: 'interactivity' } +]; +export const VALID_MODULES = new Set(MODULE_DEFINITIONS.map(m => m.value)); + +export function slugify(text) { + if (!text) return ''; + return text + .toString() + .toLowerCase() + .trim() + .replace(/_/g, '-') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9\-]+/g, '') + .replace(/\-\-+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +export function suggestNamespace(name) { + if (!name || typeof name !== 'string') return 'MyPlugin'; + const fillers = new Set(['for', 'the', 'and', 'of', 'to', 'a', 'in', 'on', 'with', 'by', 'an', 'or', 'at', 'from', 'is']); + const rawWords = name.trim().split(/[\s-_]+/).filter(Boolean); + if (rawWords.length === 0) return 'MyPlugin'; + const filtered = rawWords.filter(w => !fillers.has(w.toLowerCase())); + const words = filtered.length > 0 ? filtered : rawWords; + const studly = words + .map(w => w.replace(/[^A-Za-z0-9_]/g, '')) + .filter(Boolean) + .map(w => w.charAt(0).toUpperCase() + w.slice(1)) + .join(''); + return studly || 'MyPlugin'; +} + +export function suggestPrefix(name) { + if (!name) return 'myplug'; + const fillers = new Set(['for', 'the', 'and', 'of', 'to', 'a', 'in', 'on', 'with', 'by', 'an', 'or', 'at', 'from', 'is']); + const words = name.trim().split(/[\s-_]+/).filter(Boolean); + if (words.length === 0) return 'myplug'; + + const filtered = words.filter(w => !fillers.has(w.toLowerCase())); + const targetWords = filtered.length > 0 ? filtered : words; + let prefix = targetWords.length === 1 + ? targetWords[0].toLowerCase() + : targetWords.map(w => w.charAt(0).toLowerCase()).join(''); + + // WPCS's PrefixAllGlobals.ShortPrefixPassed sniff flags prefixes under 4 + // characters as a collision risk, so a short suggestion would fail the + // scaffold's own lint step by default. Pad it out deterministically. + if (prefix.length < 4) { + const filler = targetWords[targetWords.length - 1].toLowerCase().replace(/[^a-z0-9]/g, ''); + prefix = (prefix + filler).padEnd(4, 'x').slice(0, Math.max(4, prefix.length)); + } + + return prefix; +} + +export function validateName(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'Plugin name is required.'; + } + return true; +} + +export function validateSlug(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'Plugin slug is required.'; + } + const processed = slugify(val); + if (processed !== val || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(val)) { + return 'Plugin slug must be lowercase alphanumeric characters separated by single hyphens (e.g. my-plugin).'; + } + return true; +} + +const RESERVED_PREFIXES = new Set(['wp', 'wordpress', 'php', '__']); + +export function validatePrefix(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'Function/constant prefix is required.'; + } + if (val.includes('-')) { + return 'Prefix cannot contain hyphens because hyphens are invalid in PHP function names and constants.'; + } + if (val.length < 4 || val.length > 20) { + return 'Prefix must be between 4 and 20 characters โ€” WPCS\'s PrefixAllGlobals.ShortPrefixPassed sniff flags anything shorter as a collision risk.'; + } + if (!/^[a-z][a-z0-9_]*$/.test(val)) { + return 'Prefix must start with a lowercase letter and contain only lowercase letters, numbers, and underscores.'; + } + if (RESERVED_PREFIXES.has(val.toLowerCase())) { + return 'Prefix cannot be a reserved word ("wp", "wordpress", "php") โ€” WordPress.org plugin review and WPCS PrefixAllGlobals will reject it.'; + } + return true; +} + +export function validateNamespace(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'PHP namespace is required.'; + } + if (!/^[A-Za-z_][A-Za-z0-9_]*(\\[A-Za-z_][A-Za-z0-9_]*)*$/.test(val)) { + return 'Namespace must be one or more \\-separated segments (e.g. MyPlugin or Vendor\\MyPlugin), each starting with a letter or underscore and containing only ASCII letters, numbers, and underscores.'; + } + return true; +} + +export function validateEmail(val) { + if (val === undefined || val === null || val === '') return true; + if (typeof val === 'string' && val.trim().length > 0) { + // Practical email shape check (not full RFC 5322): one local part, one "@", + // a domain with at least one dot, no whitespace. Good enough to reject + // obvious garbage like "@@@@" without rejecting real-world addresses. + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val.trim())) { + return 'Author email must be a valid address (e.g. name@example.com).'; + } + } + return true; +} + +export function validateModules(modules) { + if (!modules || modules.length === 0) return true; + const unknown = modules.filter(m => !VALID_MODULES.has(m)); + if (unknown.length > 0) { + const known = [...VALID_MODULES].join(', '); + return `Unknown module(s): ${unknown.join(', ')}. Valid modules are: ${known}.`; + } + return true; +} + +export function validateMinPhp(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'Minimum PHP version is required.'; + } + if (!/^\d+\.\d+$/.test(val.trim())) { + return 'Minimum PHP version must be in format X.Y (e.g. 8.0).'; + } + return true; +} + +export function validateOutputDir(val) { + if (!val || typeof val !== 'string' || val.trim().length === 0) { + return 'Output directory is required.'; + } + // Deliberately unrestricted beyond "non-empty": scaffolding into a sibling + // directory (e.g. --out ../plugins/my-plugin) is the common WP dev workflow. + // The real safety net is runGenerator()'s non-empty-directory guard below. + return true; +} + +export function validateAll(answers) { + const checks = [ + { field: 'name', result: validateName(answers.name) }, + { field: 'slug', result: validateSlug(answers.slug) }, + { field: 'namespace', result: validateNamespace(answers.namespace) }, + { field: 'prefix', result: validatePrefix(answers.prefix) }, + { field: 'email', result: validateEmail(answers.authorEmail) }, + { field: 'modules', result: validateModules(answers.modules) }, + { field: 'minPhp', result: validateMinPhp(answers.minPhp) }, + { field: 'outputDir', result: validateOutputDir(answers.outputDir) } + ]; + + for (const check of checks) { + if (check.result !== true) { + return check.result; + } + } + return true; +} + +function parseCLIArgs() { + const options = { + help: { type: 'boolean', short: 'h' }, + version: { type: 'boolean', short: 'v' }, + yes: { type: 'boolean', short: 'y' }, + name: { type: 'string' }, + slug: { type: 'string' }, + namespace: { type: 'string' }, + prefix: { type: 'string' }, + author: { type: 'string' }, + email: { type: 'string' }, + 'author-uri': { type: 'string' }, + description: { type: 'string' }, + 'min-php': { type: 'string' }, + out: { type: 'string' }, + modules: { type: 'string' }, + react: { type: 'boolean' }, + 'no-react': { type: 'boolean' } + }; + + try { + const parsed = parseArgs({ options, allowPositionals: true }); + return parsed.values; + } catch (err) { + console.error(`โŒ Invalid argument: ${err.message}`); + process.exit(1); + } +} + +function showHelp() { + console.log(` +Usage: create-wp-plugin-cli [options] + +Options: + -h, --help Show help text + -v, --version Show version number + -y, --yes Skip interactive prompts and generate plugin non-interactively + --name Plugin name + --slug Plugin slug + --namespace PHP namespace + --prefix Function/constant prefix + --author Author name + --email Author email + --author-uri Author URI / GitHub URL + --description Plugin description + --min-php Minimum PHP version + --out Output directory + --modules Comma-separated list of modules (admin_settings,shortcode,rest_api,ajax_handler,cpt_taxonomy,cron,elementor_widget,woocommerce_hooks,interactivity) + --react Include React admin app build pipeline (wp-admin only) + --no-react Do not include React admin app build pipeline +`); +} + +function showVersion() { + const pkgPath = path.join(__dirname, 'package.json'); + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + console.log(pkg.version); +} + +function parseModules(modulesStr) { + if (modulesStr === undefined || modulesStr === null) return []; + if (modulesStr.trim() === '') return []; + return modulesStr.split(',').map(m => m.trim()).filter(Boolean); +} + +async function main() { + const flags = parseCLIArgs(); + + if (flags.help) { + showHelp(); + process.exit(0); + } + + if (flags.version) { + showVersion(); + process.exit(0); + } + + let answers; + + if (flags.yes) { + if (!flags.name) { + console.error('โŒ Error: --name is required when --yes is set.'); + process.exit(1); + } + if (!flags.out) { + console.error('โŒ Error: --out is required when --yes is set.'); + process.exit(1); + } + + const name = flags.name; + const slug = flags.slug || slugify(name); + const namespace = flags.namespace || suggestNamespace(name); + const prefix = flags.prefix || suggestPrefix(name); + const authorName = flags.author || ''; + const authorEmail = flags.email || ''; + const authorUri = flags['author-uri'] || ''; + const description = flags.description || 'A powerful modern WordPress plugin scaffold.'; + const minPhp = flags['min-php'] || '8.0'; + const useReact = Boolean(flags.react); + const modules = flags.modules !== undefined ? parseModules(flags.modules) : []; + const outputDir = flags.out; + + answers = { + name, + slug, + namespace, + prefix, + authorName, + authorEmail, + authorUri, + description, + minPhp, + useReact, + modules, + outputDir + }; + + const valid = validateAll(answers); + if (valid !== true) { + console.error(`โŒ Validation error: ${valid}`); + process.exit(1); + } + } else { + console.log('\n๐Ÿš€ Welcome to create-wp-plugin-cli scaffold generator!\n'); + + const initialModules = flags.modules !== undefined ? parseModules(flags.modules) : []; + + const choices = MODULE_DEFINITIONS.map(c => ({ + ...c, + selected: initialModules.includes(c.value) + })); + + const questions = [ + { + type: 'text', + name: 'name', + message: '1. Plugin name:', + initial: flags.name || '', + validate: validateName + }, + { + type: 'text', + name: 'slug', + message: '2. Plugin slug:', + initial: flags.slug || ((prev, values) => slugify(values.name)), + validate: validateSlug + }, + { + type: 'text', + name: 'namespace', + message: '3. PHP namespace:', + initial: flags.namespace || ((prev, values) => suggestNamespace(values.name)), + validate: validateNamespace + }, + { + type: 'text', + name: 'prefix', + message: '4. Function/constant prefix:', + initial: flags.prefix || ((prev, values) => suggestPrefix(values.name)), + validate: validatePrefix + }, + { + type: 'text', + name: 'authorName', + message: '5. Author name:', + initial: flags.author || '' + }, + { + type: 'text', + name: 'authorEmail', + message: '6. Author email:', + initial: flags.email || '', + validate: validateEmail + }, + { + type: 'text', + name: 'authorUri', + message: '7. Author URI / GitHub URL:', + initial: flags['author-uri'] || '' + }, + { + type: 'text', + name: 'description', + message: '8. Description (one line):', + initial: flags.description || 'A powerful modern WordPress plugin scaffold.' + }, + { + type: 'text', + name: 'minPhp', + message: '9. Minimum PHP version:', + initial: flags['min-php'] || '8.0', + validate: validateMinPhp + }, + { + type: 'confirm', + name: 'useReact', + message: '10. Include React admin app build pipeline (@wordpress/scripts, wp-admin only)?', + initial: Boolean(flags.react) + }, + { + type: 'multiselect', + name: 'modules', + message: '11. Modules to include (multi-select, space to toggle):', + choices, + hint: '- Space to select. Return to submit' + }, + { + type: 'text', + name: 'outputDir', + message: '12. Output directory:', + initial: flags.out || ((prev, values) => `./${values.slug}`), + validate: validateOutputDir + } + ]; + + answers = await prompts(questions, { + onCancel: () => { + console.log('\nOperation cancelled.'); + process.exit(1); + } + }); + + if (!answers.name) { + console.log('\nOperation cancelled.'); + process.exit(1); + } + + console.log('\nSummary:'); + console.log(` Name: ${answers.name}`); + console.log(` Slug: ${answers.slug}`); + console.log(` Namespace: ${answers.namespace}`); + console.log(` Prefix: ${answers.prefix}`); + console.log(` Author: ${answers.authorName || '(none)'}`); + console.log(''); + + const confirm = await prompts({ + type: 'confirm', + name: 'value', + message: 'Proceed with these values?', + initial: true + }, { + onCancel: () => { + console.log('\nOperation cancelled.'); + process.exit(1); + } + }); + + if (!confirm.value) { + console.log('\nOperation cancelled.'); + process.exit(0); + } + } + + try { + runGenerator(answers); + } catch (err) { + console.error(`\nโŒ Error: ${err.message}`); + process.exit(1); + } +} + +export function runGenerator(answers) { + answers.outputDir = answers.outputDir || answers.out; + const outputDir = answers.outputDir; + const targetDir = path.resolve(process.cwd(), outputDir); + + if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length > 0) { + throw new Error(`Directory "${outputDir}" already exists and is not empty.`); + } + + fs.mkdirSync(targetDir, { recursive: true }); + + const selectedModules = answers.modules || []; + const hasInteractivity = selectedModules.includes('interactivity'); + const hasWoo = selectedModules.includes('woocommerce_hooks'); + + const requiredPlugins = []; + if (selectedModules.includes('elementor_widget')) requiredPlugins.push('elementor'); + if (hasWoo) requiredPlugins.push('woocommerce'); + + let pluginHeaderExtra = ''; + if (requiredPlugins.length > 0) { + pluginHeaderExtra += ` * Requires Plugins: ${requiredPlugins.join(', ')}\n`; + } + if (selectedModules.includes('elementor_widget')) { + pluginHeaderExtra += ' * Elementor tested up to: 3.27.0\n * Elementor Pro tested up to: 3.27.0\n'; + } + + // The Interactivity API (wp_interactivity_state, Script Modules) requires WP 6.5+. + // The Cart Summary block's block.json "render" field requires WP 6.4+. + const requiredWpVersion = hasInteractivity ? '6.5' : (hasWoo ? '6.4' : '6.0'); + + // php-stubs/woocommerce-stubs gives PHPCS/Intelephense real class definitions for + // WC_Payment_Gateway, WC_Shipping_Method, WC_Email, WC_Product, etc. + const composerExtraRequireDev = hasWoo ? ',\n\t\t"php-stubs/woocommerce-stubs": "^9.0"' : ''; + const vscodeExtraStubPath = hasWoo ? ',\n\t\t"vendor/php-stubs/woocommerce-stubs/woocommerce-stubs.php"' : ''; + + const woocommerceHpos = hasWoo + ? `add_action( + 'before_woocommerce_init', + function () { + if ( class_exists( \\Automattic\\WooCommerce\\Utilities\\FeaturesUtil::class ) ) { + \\Automattic\\WooCommerce\\Utilities\\FeaturesUtil::declare_compatibility( 'custom_order_tables', ${answers.prefix.toUpperCase()}_FILE, true ); + } + } +);\n\n` + : ''; + + const replacements = { + '{{PLUGIN_NAME}}': answers.name, + '{{SLUG}}': answers.slug, + '{{NS}}': answers.namespace, + '{{NS_ESCAPED}}': answers.namespace.replace(/\\/g, '\\\\'), + '{{PREFIX}}': answers.prefix.toLowerCase(), + '{{PREFIX_UPPER}}': answers.prefix.toUpperCase(), + '{{AUTHOR}}': answers.authorName, + '{{AUTHOR_EMAIL}}': answers.authorEmail || 'author@example.com', + '{{COMPOSER_VENDOR}}': slugify(answers.authorName) || 'vendor', + '{{AUTHOR_URI}}': answers.authorUri, + '{{DESCRIPTION}}': answers.description, + '{{MIN_PHP}}': answers.minPhp, + '{{REQUIRES_AT_LEAST}}': requiredWpVersion, + '{{VERSION}}': '0.1.0', + '{{YEAR}}': new Date().getFullYear().toString(), + '{{PLUGIN_HEADER_EXTRA}}': pluginHeaderExtra, + '{{WOOCOMMERCE_HPOS}}': woocommerceHpos, + '{{COMPOSER_EXTRA_REQUIRE_DEV}}': composerExtraRequireDev, + '{{VSCODE_EXTRA_STUB_PATH}}': vscodeExtraStubPath + }; + + function processTemplateContent(content) { + let result = content; + for (const [key, val] of Object.entries(replacements)) { + result = result.replaceAll(key, () => val); + } + return result; + } + + function writeTemplateFile(srcPath, destRelativePath) { + const raw = fs.readFileSync(srcPath, 'utf8'); + const processed = processTemplateContent(raw); + const destPath = path.join(targetDir, destRelativePath); + fs.mkdirSync(path.dirname(destPath), { recursive: true }); + fs.writeFileSync(destPath, processed, 'utf8'); + } + + const templatesDir = path.join(__dirname, 'templates'); + + // Copy standard templates + writeTemplateFile(path.join(templatesDir, 'src/Contracts/Registrable.php'), 'src/Contracts/Registrable.php'); + writeTemplateFile(path.join(templatesDir, 'plugin-main.php'), `${answers.slug}.php`); + writeTemplateFile(path.join(templatesDir, 'composer.json'), 'composer.json'); + writeTemplateFile(path.join(templatesDir, 'phpcs.xml'), 'phpcs.xml'); + writeTemplateFile(path.join(templatesDir, 'src/CLI/Commands.php'), 'src/CLI/Commands.php'); + writeTemplateFile(path.join(templatesDir, 'tests/bootstrap.php'), 'tests/bootstrap.php'); + writeTemplateFile(path.join(templatesDir, 'phpunit.xml.dist'), 'phpunit.xml.dist'); + writeTemplateFile(path.join(templatesDir, 'tests/Unit/Example_Test.php'), 'tests/Unit/Example_Test.php'); + writeTemplateFile(path.join(templatesDir, 'gitignore.tpl'), '.gitignore'); + writeTemplateFile(path.join(templatesDir, 'editorconfig.tpl'), '.editorconfig'); + writeTemplateFile(path.join(templatesDir, 'distignore.tpl'), '.distignore'); + writeTemplateFile(path.join(templatesDir, 'assets/css/main.css'), 'assets/css/main.css'); + writeTemplateFile(path.join(templatesDir, 'assets/js/main.js'), 'assets/js/main.js'); + writeTemplateFile(path.join(templatesDir, 'readme.txt'), 'readme.txt'); + writeTemplateFile(path.join(templatesDir, 'languages/.gitkeep'), 'languages/.gitkeep'); + writeTemplateFile(path.join(templatesDir, '.vscode/php.code-snippets'), '.vscode/php.code-snippets'); + writeTemplateFile(path.join(templatesDir, '.vscode/extensions.json'), '.vscode/extensions.json'); + writeTemplateFile(path.join(templatesDir, '.vscode/settings.json'), '.vscode/settings.json'); + + // Selected modules mapping + const moduleRegistrations = []; + const bootHooks = []; + + if (selectedModules.includes('admin_settings')) { + let settingsContent = fs.readFileSync(path.join(templatesDir, 'src/Admin/Settings_Page.php'), 'utf8'); + const reactAdminRoot = answers.useReact ? '\t\t\t
\n' : ''; + settingsContent = settingsContent.replace('{{REACT_ADMIN_ROOT}}', () => reactAdminRoot); + settingsContent = processTemplateContent(settingsContent); + const settingsDest = path.join(targetDir, 'src/Admin/Settings_Page.php'); + fs.mkdirSync(path.dirname(settingsDest), { recursive: true }); + fs.writeFileSync(settingsDest, settingsContent, 'utf8'); + moduleRegistrations.push('\n\t\t$services[\'admin_settings\'] = new Admin\\Settings_Page();'); + } + if (selectedModules.includes('shortcode')) { + writeTemplateFile(path.join(templatesDir, 'src/Frontend/Shortcode.php'), 'src/Frontend/Shortcode.php'); + moduleRegistrations.push('\n\t\t$services[\'shortcode\'] = new Frontend\\Shortcode();'); + } + if (selectedModules.includes('rest_api')) { + writeTemplateFile(path.join(templatesDir, 'src/Rest/Rest_Controller.php'), 'src/Rest/Rest_Controller.php'); + moduleRegistrations.push('\n\t\t$services[\'rest\'] = new Rest\\Rest_Controller();'); + } + if (selectedModules.includes('ajax_handler')) { + writeTemplateFile(path.join(templatesDir, 'src/Ajax/Ajax_Handler.php'), 'src/Ajax/Ajax_Handler.php'); + moduleRegistrations.push('\n\t\t$services[\'ajax\'] = new Ajax\\Ajax_Handler();'); + } + if (selectedModules.includes('cpt_taxonomy')) { + writeTemplateFile(path.join(templatesDir, 'src/PostTypes/Post_Types.php'), 'src/PostTypes/Post_Types.php'); + moduleRegistrations.push('\n\t\t$services[\'post_types\'] = new PostTypes\\Post_Types();'); + } + if (selectedModules.includes('cron')) { + writeTemplateFile(path.join(templatesDir, 'src/Cron/Scheduler.php'), 'src/Cron/Scheduler.php'); + moduleRegistrations.push('\n\t\t$services[\'cron\'] = new Cron\\Scheduler();'); + } + if (selectedModules.includes('elementor_widget')) { + writeTemplateFile(path.join(templatesDir, '.vscode/php-elementor.code-snippets'), '.vscode/php-elementor.code-snippets'); + writeTemplateFile(path.join(templatesDir, 'src/Elementor/Dependency_Notice.php'), 'src/Elementor/Dependency_Notice.php'); + writeTemplateFile(path.join(templatesDir, 'src/Widgets/Sample_Widget.php'), 'src/Widgets/Sample_Widget.php'); + writeTemplateFile(path.join(templatesDir, 'assets/css/widgets/sample-widget.css'), 'assets/css/widgets/sample-widget.css'); + writeTemplateFile(path.join(templatesDir, 'assets/js/widgets/sample-widget.js'), 'assets/js/widgets/sample-widget.js'); + moduleRegistrations.push('\n\t\t$services[\'elementor_notice\'] = new Elementor\\Dependency_Notice();'); + // Hook registration is a boot()-time side effect, not a service build step โ€” it must run + // even when get_instance() is called with an injected $services array that skips build_services(). + bootHooks.push('\t\tadd_action( \'wp_enqueue_scripts\', array( $this, \'register_widget_assets\' ) );'); + bootHooks.push('\t\tadd_action( \'elementor/editor/after_enqueue_styles\', array( $this, \'register_widget_assets\' ) );'); + bootHooks.push('\t\tadd_action( \'elementor/widgets/register\', array( $this, \'register_widgets\' ) );'); + } + if (selectedModules.includes('woocommerce_hooks')) { + writeTemplateFile(path.join(templatesDir, 'src/Woo/Woo_Hooks.php'), 'src/Woo/Woo_Hooks.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Gateways/Gateway.php'), 'src/Woo/Gateways/Gateway.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Gateways/Blocks_Payment_Method_Type.php'), 'src/Woo/Gateways/Blocks_Payment_Method_Type.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Shipping/Shipping_Method.php'), 'src/Woo/Shipping/Shipping_Method.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Emails/Custom_Email.php'), 'src/Woo/Emails/Custom_Email.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Products/Custom_Product.php'), 'src/Woo/Products/Custom_Product.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Blocks/Integration.php'), 'src/Woo/Blocks/Integration.php'); + writeTemplateFile(path.join(templatesDir, 'src/Woo/Blocks/Cart_Summary_Block.php'), 'src/Woo/Blocks/Cart_Summary_Block.php'); + writeTemplateFile(path.join(templatesDir, 'woo-email-templates/emails/custom-email.php'), `templates/emails/${answers.prefix.toLowerCase()}-custom-email.php`); + writeTemplateFile(path.join(templatesDir, 'woo-email-templates/emails/plain/custom-email.php'), `templates/emails/plain/${answers.prefix.toLowerCase()}-custom-email.php`); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/wc-gateway-block.js'), 'assets/src/wc-gateway-block.js'); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks-integration.js'), 'assets/src/blocks-integration.js'); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/block.json'), 'assets/src/blocks/cart-summary/block.json'); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/index.js'), 'assets/src/blocks/cart-summary/index.js'); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/blocks/cart-summary/render.php'), 'assets/src/blocks/cart-summary/render.php'); + moduleRegistrations.push('\n\t\t$services[\'woo\'] = new Woo\\Woo_Hooks();'); + } + if (selectedModules.includes('interactivity')) { + writeTemplateFile(path.join(templatesDir, 'src/Frontend/Interactivity.php'), 'src/Frontend/Interactivity.php'); + writeTemplateFile(path.join(templatesDir, 'react/assets/src/view.js'), 'assets/src/view.js'); + moduleRegistrations.push('\n\t\t$services[\'interactivity\'] = new Frontend\\Interactivity();'); + } + + let elementorWidgetMethods = ''; + if (selectedModules.includes('elementor_widget')) { + elementorWidgetMethods = `\t/** +\t * Get auto-discovered Elementor widget class names (cached via transient unless SCRIPT_DEBUG is active). +\t * +\t * @return array List of widget class names. +\t */ +\tprivate function get_widget_classes(): array { +\t\tif ( ! did_action( 'elementor/loaded' ) ) { +\t\t\treturn array(); +\t\t} + +\t\tif ( ! ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ) { +\t\t\t$cached = get_transient( '{{PREFIX}}_elementor_widgets' ); +\t\t\tif ( is_array( $cached ) ) { +\t\t\t\treturn $cached; +\t\t\t} +\t\t} + +\t\t$widget_classes = array(); +\t\t$files = glob( __DIR__ . '/Widgets/*.php' ) ?: array(); // phpcs:ignore Universal.Operators.DisallowShortTernary.Found + +\t\tforeach ( $files as $file ) { +\t\t\t$class_name = __NAMESPACE__ . '\\\\Widgets\\\\' . basename( $file, '.php' ); + +\t\t\tif ( ! class_exists( $class_name ) ) { +\t\t\t\tcontinue; +\t\t\t} + +\t\t\t$reflection = new \\ReflectionClass( $class_name ); + +\t\t\tif ( $reflection->isAbstract() || ! $reflection->isSubclassOf( '\\\\Elementor\\\\Widget_Base' ) ) { +\t\t\t\tcontinue; +\t\t\t} + +\t\t\t$widget_classes[] = $class_name; +\t\t} + +\t\tset_transient( '{{PREFIX}}_elementor_widgets', $widget_classes, DAY_IN_SECONDS ); +\t\treturn $widget_classes; +\t} + +\t/** +\t * Auto-register per-widget stylesheets and scripts derived by convention from widget class names. +\t * +\t * @return void +\t */ +\tpublic function register_widget_assets(): void { +\t\tif ( ! did_action( 'elementor/loaded' ) ) { +\t\t\treturn; +\t\t} + +\t\t$widget_classes = $this->get_widget_classes(); + +\t\tforeach ( $widget_classes as $class_name ) { +\t\t\t$short_name = substr( $class_name, strrpos( $class_name, '\\\\' ) + 1 ); +\t\t\t$slug = strtolower( str_replace( '_', '-', $short_name ) ); +\t\t\t$handle = '{{PREFIX}}-' . $slug; +\t\t\t$css_file = {{PREFIX_UPPER}}_PATH . 'assets/css/widgets/' . $slug . '.css'; +\t\t\t$js_file = {{PREFIX_UPPER}}_PATH . 'assets/js/widgets/' . $slug . '.js'; + +\t\t\tif ( file_exists( $css_file ) ) { +\t\t\t\twp_register_style( +\t\t\t\t\t$handle, +\t\t\t\t\t{{PREFIX_UPPER}}_URL . 'assets/css/widgets/' . $slug . '.css', +\t\t\t\t\tarray(), +\t\t\t\t\t{{PREFIX_UPPER}}_VERSION +\t\t\t\t); +\t\t\t} + +\t\t\tif ( file_exists( $js_file ) ) { +\t\t\t\twp_register_script( +\t\t\t\t\t$handle, +\t\t\t\t\t{{PREFIX_UPPER}}_URL . 'assets/js/widgets/' . $slug . '.js', +\t\t\t\t\tarray( 'jquery' ), +\t\t\t\t\t{{PREFIX_UPPER}}_VERSION, +\t\t\t\t\ttrue +\t\t\t\t); +\t\t\t} +\t\t} +\t} + +\t/** +\t * Auto-register discovered concrete Elementor widget classes. +\t * +\t * @param object $widgets_manager Elementor widgets manager. +\t * @return void +\t */ +\tpublic function register_widgets( $widgets_manager ): void { +\t\tif ( ! did_action( 'elementor/loaded' ) ) { +\t\t\treturn; +\t\t} + +\t\t$widget_classes = $this->get_widget_classes(); + +\t\tforeach ( $widget_classes as $class_name ) { +\t\t\tif ( class_exists( $class_name ) ) { +\t\t\t\t$widgets_manager->register( new $class_name() ); +\t\t\t} +\t\t} +\t}\n\n`; + } + + // React admin app (wp-admin only) + Interactivity API (frontend) + WooCommerce + // Blocks gateway build pipeline. These are independent toggles that share one + // @wordpress/scripts build: + // useReact -> assets/src/index.js (wp-admin React app) + // interactivity mod -> assets/src/view.js (frontend Interactivity API store) + // woocommerce_hooks -> assets/src/wc-gateway-block.js (block checkout payment method) + const needsBuildPipeline = answers.useReact || hasInteractivity || hasWoo; + + let reactAssetsRegistration = ''; + let readmeReactInstall = ''; + let readmeReactScripts = ''; + let ciNodeJob = ''; + + if (answers.useReact) { + writeTemplateFile(path.join(templatesDir, 'react/assets/src/index.js'), 'assets/src/index.js'); + + let assetsContent = fs.readFileSync(path.join(templatesDir, 'src/Admin/Assets.php'), 'utf8'); + const reactAdminHookGuard = selectedModules.includes('admin_settings') + ? '\t\tif ( \'settings_page_{{SLUG}}\' !== $hook_suffix ) {\n\t\t\treturn;\n\t\t}\n\n' + : '\t\t// TODO: narrow this to your plugin\'s own admin screen(s), e.g. compare $hook_suffix.\n'; + assetsContent = assetsContent.replace('{{REACT_ADMIN_HOOK_GUARD}}', () => reactAdminHookGuard); + assetsContent = processTemplateContent(assetsContent); + const assetsDestPath = path.join(targetDir, 'src/Admin/Assets.php'); + fs.mkdirSync(path.dirname(assetsDestPath), { recursive: true }); + fs.writeFileSync(assetsDestPath, assetsContent, 'utf8'); + + reactAssetsRegistration = '\n\t\t$services[\'assets\'] = new Admin\\Assets();\n'; + } + + if (needsBuildPipeline) { + writeTemplateFile(path.join(templatesDir, 'react/package.json'), 'package.json'); + + // wp-scripts only auto-detects a single "src/index.js" entry (or, if any + // block.json exists under the src dir, ONLY the entries it derives from + // block.json files โ€” "src/index.js" is silently dropped in that case). + // Once we ship more than one of: the admin app, the Interactivity API view + // script, the WooCommerce Blocks gateway/integration scripts, or a native + // block (block.json), we must override entry resolution via webpack.config.js + // โ€” wp-scripts picks this file up automatically if present at the project root. + // + // IMPORTANT: @wordpress/scripts assigns `entry` as a *function* (webpack's + // lazy-entry form) so it can glob for block.json files at build time, not a + // plain object โ€” `{ ...defaultConfig.entry }` silently spreads to `{}` and + // drops every auto-discovered block entry. It must be invoked, not spread. + if (hasInteractivity || hasWoo) { + const entries = []; + if (answers.useReact) entries.push('\t\tindex: \'./assets/src/index.js\','); + if (hasInteractivity) entries.push('\t\tview: \'./assets/src/view.js\','); + if (hasWoo) { + entries.push('\t\t\'wc-gateway-block\': \'./assets/src/wc-gateway-block.js\','); + entries.push('\t\t\'blocks-integration\': \'./assets/src/blocks-integration.js\','); + } + + const webpackConfig = `const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); + +/** + * Merges our explicit entries with wp-scripts' own lazily-computed entry + * function so native blocks (block.json under assets/src/blocks/**) keep + * building automatically alongside them. wp-scripts loads this file + * automatically when present at the project root. + */ +module.exports = { + ...defaultConfig, + entry: () => ( { + ...( typeof defaultConfig.entry === 'function' ? defaultConfig.entry() : defaultConfig.entry ), +${entries.join('\n')} + } ), +}; +`; + fs.writeFileSync(path.join(targetDir, 'webpack.config.js'), webpackConfig, 'utf8'); + } + + readmeReactInstall = '3. Run `npm install` and `npm run build` to compile JS assets.\n > Note: `assets/build` is gitignored and generated during build.'; + readmeReactScripts = '- `npm run build` โ€” Build JS assets for production.\n- `npm run start` โ€” Start JS asset dev server in watch mode.'; + + ciNodeJob = ` + node-build: + name: Build JS Assets + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Node Dependencies + run: npm install + + - name: Build Assets + run: npm run build`; + } + + // Process Plugin.php template with dynamic registrations + let pluginContent = fs.readFileSync(path.join(templatesDir, 'src/Plugin.php'), 'utf8'); + pluginContent = pluginContent.replace('{{REACT_ASSETS_REGISTRATION}}', () => reactAssetsRegistration); + pluginContent = pluginContent.replace('{{MODULE_REGISTRATIONS}}', () => moduleRegistrations.length > 0 ? moduleRegistrations.join('\n') + '\n' : ''); + pluginContent = pluginContent.replace('{{ELEMENTOR_WIDGET_METHODS}}', () => elementorWidgetMethods); + pluginContent = pluginContent.replace('{{BOOT_HOOKS}}', () => bootHooks.length > 0 ? bootHooks.join('\n') + '\n' : ''); + pluginContent = processTemplateContent(pluginContent); + const pluginDestPath = path.join(targetDir, 'src/Plugin.php'); + fs.mkdirSync(path.dirname(pluginDestPath), { recursive: true }); + fs.writeFileSync(pluginDestPath, pluginContent, 'utf8'); + + const allPhpVersions = ['8.0', '8.1', '8.2', '8.3']; + const minPhpNum = parseFloat(answers.minPhp || '8.0'); + let validMatrixVersions = allPhpVersions.filter(v => parseFloat(v) >= minPhpNum); + if (!validMatrixVersions.includes(answers.minPhp)) { + validMatrixVersions.push(answers.minPhp); + } + validMatrixVersions.sort((a, b) => parseFloat(a) - parseFloat(b)); + const ciPhpMatrix = JSON.stringify(validMatrixVersions).replace(/"/g, "'"); + + // Process ci.yml with dynamic node job + let ciContent = fs.readFileSync(path.join(templatesDir, 'github/workflows/ci.yml'), 'utf8'); + ciContent = ciContent.replace('{{CI_PHP_MATRIX}}', () => ciPhpMatrix); + ciContent = ciContent.replace('{{CI_NODE_JOB}}', () => ciNodeJob); + ciContent = processTemplateContent(ciContent); + const ciDestPath = path.join(targetDir, '.github/workflows/ci.yml'); + fs.mkdirSync(path.dirname(ciDestPath), { recursive: true }); + fs.writeFileSync(ciDestPath, ciContent, 'utf8'); + + // Process Activator.php, Deactivator.php, uninstall.php with dynamic bodies + const activatorLines = []; + const deactivatorLines = []; + const uninstallLines = []; + + if (selectedModules.includes('cpt_taxonomy')) { + activatorLines.push('\t\t$post_types = new PostTypes\\Post_Types();'); + activatorLines.push('\t\t$post_types->register_cpt_and_taxonomy();'); + activatorLines.push('\t\tflush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules'); + deactivatorLines.push('\t\tflush_rewrite_rules(); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.flush_rewrite_rules_flush_rewrite_rules'); + } + + if (selectedModules.includes('cron')) { + activatorLines.push('\t\tif ( ! wp_next_scheduled( \'{{PREFIX}}_cron_event\' ) ) {'); + activatorLines.push('\t\t\twp_schedule_event( time(), \'hourly\', \'{{PREFIX}}_cron_event\' );'); + activatorLines.push('\t\t}'); + deactivatorLines.push('\t\twp_clear_scheduled_hook( \'{{PREFIX}}_cron_event\' );'); + uninstallLines.push('\twp_clear_scheduled_hook( \'{{PREFIX}}_cron_event\' );'); + } + + if (selectedModules.includes('elementor_widget')) { + activatorLines.push('\t\tdelete_transient( \'{{PREFIX}}_elementor_widgets\' );'); + } + + if (selectedModules.includes('admin_settings')) { + uninstallLines.push('\tdelete_option( \'{{PREFIX}}_option_name\' );'); + } + + const activatorBody = activatorLines.length > 0 ? activatorLines.join('\n') + '\n' : ''; + const deactivatorBody = deactivatorLines.length > 0 ? deactivatorLines.join('\n') + '\n' : ''; + const uninstallBody = uninstallLines.length > 0 ? uninstallLines.join('\n') + '\n' : ''; + + let activatorContent = fs.readFileSync(path.join(templatesDir, 'src/Core/Activator.php'), 'utf8'); + activatorContent = activatorContent.replace('{{ACTIVATOR_BODY}}', () => activatorBody); + activatorContent = processTemplateContent(activatorContent); + const activatorDestPath = path.join(targetDir, 'src/Core/Activator.php'); + fs.mkdirSync(path.dirname(activatorDestPath), { recursive: true }); + fs.writeFileSync(activatorDestPath, activatorContent, 'utf8'); + + let deactivatorContent = fs.readFileSync(path.join(templatesDir, 'src/Core/Deactivator.php'), 'utf8'); + deactivatorContent = deactivatorContent.replace('{{DEACTIVATOR_BODY}}', () => deactivatorBody); + deactivatorContent = processTemplateContent(deactivatorContent); + const deactivatorDestPath = path.join(targetDir, 'src/Core/Deactivator.php'); + fs.mkdirSync(path.dirname(deactivatorDestPath), { recursive: true }); + fs.writeFileSync(deactivatorDestPath, deactivatorContent, 'utf8'); + + let uninstallContent = fs.readFileSync(path.join(templatesDir, 'uninstall.php'), 'utf8'); + uninstallContent = uninstallContent.replace('{{UNINSTALL_BODY}}', () => uninstallBody); + uninstallContent = processTemplateContent(uninstallContent); + const uninstallDestPath = path.join(targetDir, 'uninstall.php'); + fs.writeFileSync(uninstallDestPath, uninstallContent, 'utf8'); + + // Process README.md with dynamic React sections + let readmeContent = fs.readFileSync(path.join(templatesDir, 'README.md'), 'utf8'); + readmeContent = readmeContent.replace('{{README_REACT_INSTALL}}', () => readmeReactInstall); + readmeContent = readmeContent.replace('{{README_REACT_SCRIPTS}}', () => readmeReactScripts); + readmeContent = processTemplateContent(readmeContent); + const readmeDestPath = path.join(targetDir, 'README.md'); + fs.writeFileSync(readmeDestPath, readmeContent, 'utf8'); + + console.log(`\nโœ… Successfully scaffolded plugin "${answers.name}" in ${answers.outputDir}!\n`); + console.log('Next steps:'); + console.log(` cd ${answers.outputDir}`); + console.log(' composer install'); + if (answers.useReact) { + console.log(' npm install'); + console.log(' npm run build'); + } + console.log(' composer lint'); + console.log(' composer test'); + console.log(' git init && git add -A && git commit -m "scaffold"\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'); +} + +function isRunAsScript() { + if (!process.argv[1]) return false; + // npm/npx install the CLI behind a symlink on macOS/Linux (bin/create-wp-plugin-cli + // -> ../lib/node_modules/create-wp-plugin-cli/index.js). process.argv[1] is the + // symlink path while __filename is already resolved to the real file, so compare + // realpaths rather than raw paths or this guard silently never runs main(). + let invokedPath = process.argv[1]; + try { + invokedPath = fs.realpathSync(invokedPath); + } catch { + // Path doesn't exist as given (e.g. invoked via a loader that fabricates + // argv[1]) โ€” fall back to the raw value so the comparison below still applies. + } + return path.resolve(__filename) === path.resolve(invokedPath); +} + +if (isRunAsScript()) { + main().catch(err => { + console.error('An error occurred:', err); + process.exit(1); + }); +} diff --git a/package-lock.json b/package-lock.json index ee8f44d..94bb3a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,50 +1,50 @@ -{ - "name": "create-wp-plugin-cli", - "version": "1.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "create-wp-plugin-cli", - "version": "1.0.1", - "license": "GPL-2.0-or-later", - "dependencies": { - "prompts": "^2.4.2" - }, - "bin": { - "create-wp-plugin-cli": "index.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "license": "MIT" - } - } -} +{ + "name": "create-wp-plugin-cli", + "version": "1.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "create-wp-plugin-cli", + "version": "1.0.1", + "license": "GPL-2.0-or-later", + "dependencies": { + "prompts": "^2.4.2" + }, + "bin": { + "create-wp-plugin-cli": "index.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 06303e3..72a6175 100644 --- a/package.json +++ b/package.json @@ -1,49 +1,49 @@ -{ - "name": "create-wp-plugin-cli", - "version": "1.0.1", - "description": "Interactive scaffold generator for modern WordPress plugins โ€” PSR-4, WPCS/VIP standards, PHPUnit, WP-CLI, WooCommerce, Interactivity API, and CI out of the box", - "main": "index.js", - "bin": { - "create-wp-plugin-cli": "index.js" - }, - "type": "module", - "engines": { - "node": ">=18" - }, - "files": [ - "index.js", - "templates/**" - ], - "scripts": { - "start": "node index.js", - "test": "node --test" - }, - "keywords": [ - "wordpress", - "plugin", - "scaffold", - "generator", - "cli", - "wpcs", - "psr-4", - "woocommerce", - "interactivity-api" - ], - "author": { - "name": "Akshat Saxena", - "email": "saxena.akshat.akshat@gmail.com", - "url": "https://github.com/akshat009" - }, - "license": "GPL-2.0-or-later", - "repository": { - "type": "git", - "url": "git+https://github.com/akshat009/create-wp-plugin-cli.git" - }, - "homepage": "https://github.com/akshat009/create-wp-plugin-cli#readme", - "bugs": { - "url": "https://github.com/akshat009/create-wp-plugin-cli/issues" - }, - "dependencies": { - "prompts": "^2.4.2" - } -} +{ + "name": "create-wp-plugin-cli", + "version": "1.0.1", + "description": "Interactive scaffold generator for modern WordPress plugins โ€” PSR-4, WPCS/VIP standards, PHPUnit, WP-CLI, WooCommerce, Interactivity API, and CI out of the box", + "main": "index.js", + "bin": { + "create-wp-plugin-cli": "index.js" + }, + "type": "module", + "engines": { + "node": ">=18" + }, + "files": [ + "index.js", + "templates/**" + ], + "scripts": { + "start": "node index.js", + "test": "node --test" + }, + "keywords": [ + "wordpress", + "plugin", + "scaffold", + "generator", + "cli", + "wpcs", + "psr-4", + "woocommerce", + "interactivity-api" + ], + "author": { + "name": "Akshat Saxena", + "email": "saxena.akshat.akshat@gmail.com", + "url": "https://github.com/akshat009" + }, + "license": "GPL-2.0-or-later", + "repository": { + "type": "git", + "url": "git+https://github.com/akshat009/create-wp-plugin-cli.git" + }, + "homepage": "https://github.com/akshat009/create-wp-plugin-cli#readme", + "bugs": { + "url": "https://github.com/akshat009/create-wp-plugin-cli/issues" + }, + "dependencies": { + "prompts": "^2.4.2" + } +} diff --git a/templates/composer.json b/templates/composer.json index f68e176..5ba0f56 100644 --- a/templates/composer.json +++ b/templates/composer.json @@ -1,5 +1,5 @@ { - "name": "vendor/{{SLUG}}", + "name": "{{COMPOSER_VENDOR}}/{{SLUG}}", "description": "{{DESCRIPTION}}", "type": "wordpress-plugin", "license": "GPL-2.0-or-later", diff --git a/templates/phpcs.xml b/templates/phpcs.xml index 31151db..f7e0d14 100644 --- a/templates/phpcs.xml +++ b/templates/phpcs.xml @@ -49,10 +49,6 @@ - - 0 - - diff --git a/tests/generator.test.js b/tests/generator.test.js index c229e74..abd5e5a 100644 --- a/tests/generator.test.js +++ b/tests/generator.test.js @@ -14,6 +14,7 @@ import { validateEmail, validateMinPhp, validateOutputDir, + validateModules, validateAll, runGenerator } from '../index.js'; @@ -33,9 +34,13 @@ test('suggestNamespace generates StudlyCase dropping filler words', () => { assert.equal(suggestNamespace(' '), 'MyPlugin'); }); -test('suggestPrefix generates lowercase 3-4 char prefix', () => { - assert.equal(suggestPrefix('My Awesome Plugin'), 'map'); +test('suggestPrefix generates lowercase prefix, at least 4 chars (WPCS ShortPrefixPassed floor)', () => { + // Initials alone ("map") are only 3 chars โ€” WPCS's PrefixAllGlobals.ShortPrefixPassed + // sniff flags anything under 4, so the suggestion pads out deterministically. + assert.equal(suggestPrefix('My Awesome Plugin'), 'mapp'); assert.equal(suggestPrefix('Plugin'), 'plugin'); + assert.ok(suggestPrefix('Go').length >= 4); + assert.ok(suggestPrefix('My Plugin').length >= 4); }); test('Group 2 Validators', () => { @@ -45,7 +50,8 @@ test('Group 2 Validators', () => { assert.equal(validateSlug('my-plugin'), true); assert.equal(typeof validateSlug('My Plugin'), 'string'); - assert.equal(validatePrefix('myp'), true); + assert.equal(validatePrefix('myplug'), true); + assert.equal(typeof validatePrefix('myp'), 'string'); // 3 chars: below WPCS's 4-char ShortPrefixPassed floor assert.equal(typeof validatePrefix('123'), 'string'); assert.equal(validateNamespace('MyPlugin'), true); @@ -66,7 +72,7 @@ test('Group 2 Validators', () => { assert.equal(validateAll({ name: 'Test Plugin', slug: 'test-plugin', - prefix: 'tp', + prefix: 'tplg', namespace: 'TestPlugin', authorEmail: 'author@example.com', minPhp: '8.0', @@ -166,10 +172,12 @@ test('Non-interactive scaffolding for Elementor variant includes php-elementor.c }); test('validatePrefix rejects reserved words', () => { + // "wp" and "php" are also caught by the 4-char length floor first, but they + // must still come back rejected either way โ€” that's what matters here. assert.equal(typeof validatePrefix('wp'), 'string'); assert.equal(typeof validatePrefix('php'), 'string'); assert.equal(typeof validatePrefix('wordpress'), 'string'); - assert.equal(validatePrefix('myp'), true); + assert.equal(validatePrefix('myplug'), true); }); test('validateOutputDir allows paths outside the current working directory', () => { @@ -178,6 +186,20 @@ test('validateOutputDir allows paths outside the current working directory', () assert.equal(typeof validateOutputDir(''), 'string'); }); +test('validateModules rejects unknown module names but allows empty/known lists', () => { + assert.equal(validateModules([]), true); + assert.equal(validateModules(undefined), true); + assert.equal(validateModules(['admin_settings', 'rest_api']), true); + assert.equal(typeof validateModules(['admin_settings', 'not_a_real_module']), 'string'); +}); + +test('validateEmail rejects garbage that merely contains "@"', () => { + assert.equal(validateEmail('name@example.com'), true); + assert.equal(validateEmail(''), true); // optional field + assert.equal(typeof validateEmail('@@@@'), 'string'); + assert.equal(typeof validateEmail('no-at-sign'), 'string'); +}); + test('runGenerator throws (does not process.exit) when the output directory is non-empty', () => { const outDir = path.join(__dirname, '../tmp-test-nonempty'); fs.mkdirSync(outDir, { recursive: true }); @@ -517,3 +539,43 @@ test('WooCommerce Cart block: native cart-summary block + Blocks Integration sca fs.rmSync(outDir, { recursive: true, force: true }); }); +test('composer.json package name derives from the author, not a literal "vendor/" placeholder', () => { + const outDir = path.join(__dirname, '../tmp-test-composer-vendor'); + runGenerator({ + name: 'Vendor Test Plugin', + slug: 'vendor-test-plugin', + prefix: 'vtpl', + namespace: 'VendorTestPlugin', + authorName: 'Jane Doe', + minPhp: '8.0', + modules: [], + useReact: false, + out: outDir + }); + + const composerJson = JSON.parse(fs.readFileSync(path.join(outDir, 'composer.json'), 'utf8')); + assert.equal(composerJson.name, 'jane-doe/vendor-test-plugin'); + + fs.rmSync(outDir, { recursive: true, force: true }); +}); + +test('composer.json package name falls back to "vendor/" when no author name is given', () => { + const outDir = path.join(__dirname, '../tmp-test-composer-vendor-fallback'); + runGenerator({ + name: 'No Author Plugin', + slug: 'no-author-plugin', + prefix: 'napl', + namespace: 'NoAuthorPlugin', + authorName: '', + minPhp: '8.0', + modules: [], + useReact: false, + out: outDir + }); + + const composerJson = JSON.parse(fs.readFileSync(path.join(outDir, 'composer.json'), 'utf8')); + assert.equal(composerJson.name, 'vendor/no-author-plugin'); + + fs.rmSync(outDir, { recursive: true, force: true }); +}); + From 6bbc4adf94318624fc859749c36024e7b2eb59ab Mon Sep 17 00:00:00 2001 From: Akshat Date: Wed, 19 Aug 2026 14:01:19 +0530 Subject: [PATCH 2/2] chore: bump version to 1.0.2 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 94bb3a1..2166d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "create-wp-plugin-cli", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "create-wp-plugin-cli", - "version": "1.0.1", + "version": "1.0.2", "license": "GPL-2.0-or-later", "dependencies": { "prompts": "^2.4.2" diff --git a/package.json b/package.json index 72a6175..b2ef036 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "create-wp-plugin-cli", - "version": "1.0.1", + "version": "1.0.2", "description": "Interactive scaffold generator for modern WordPress plugins โ€” PSR-4, WPCS/VIP standards, PHPUnit, WP-CLI, WooCommerce, Interactivity API, and CI out of the box", "main": "index.js", "bin": {