wp-plugin-init
Version:
CLI to scaffold a PHP plugin boilerplate structure
163 lines (135 loc) • 6.91 kB
JavaScript
import fs from 'fs-extra';
import path from 'path';
import chalk from 'chalk';
import { fileURLToPath } from 'url';
import { glob } from 'glob';
import { exec } from 'child_process';
import util from 'util';
const execPromise = util.promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const args = process.argv.slice(2);
const pluginName = args.find((a) => !a.startsWith('-'));
// --local=/path/to/wpsuitepress-framework uses a local path repo instead
// of the GitHub VCS — handy for developing the framework before publishing.
const localFlag = args.find((a) => a.startsWith('--local='));
const localFrameworkPath = localFlag ? localFlag.split('=')[1] : null;
if (!pluginName) {
console.log(chalk.red('❌ Please provide a plugin name. e.g. wp-plugin-init my-plugin'));
process.exit(1);
}
if (!/^[a-z][a-z0-9-]*$/.test(pluginName)) {
console.log(chalk.red('❌ Plugin name must be lowercase kebab-case (letters, numbers, dashes), starting with a letter.'));
process.exit(1);
}
// ---- Derived identifiers -------------------------------------------------
// "Suite One"
const readablePluginName = pluginName
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
const classPrefix = readablePluginName.replace(/\s+/g, ''); // SuiteOne
const constantPrefix = pluginName.replace(/[-\s]/g, '_').toUpperCase(); // SUITE_ONE
const snakeName = pluginName.replace(/[-\s]/g, '_').toLowerCase(); // suite_one
const pluginGlobal = `${classPrefix}Settings`; // SuiteOneSettings
const targetDir = path.join(process.cwd(), pluginName);
const templateDir = path.join(__dirname, 'template');
// Placeholder → value map, applied to every text file.
const replacements = [
[/__PLUGIN_TITLE__/g, readablePluginName],
[/__PLUGIN_NAME__/g, pluginName],
[/__CONST_PREFIX__/g, constantPrefix],
[/__SNAKE_NAME__/g, snakeName],
[/__PLUGIN_GLOBAL__/g, pluginGlobal],
[/PluginsNameSpaces/g, classPrefix],
];
const TEXT_EXTENSIONS = new Set([
'.php', '.js', '.mjs', '.vue', '.json', '.md', '.txt', '.css', '.html',
]);
function applyReplacements(content) {
return replacements.reduce((acc, [pattern, value]) => acc.replace(pattern, value), content);
}
(async () => {
try {
if (await fs.pathExists(targetDir)) {
console.log(chalk.red(`❌ Folder '${pluginName}' already exists.`));
return;
}
console.log(chalk.cyan(`\n🚀 Scaffolding '${readablePluginName}'...\n`));
await fs.copy(templateDir, targetDir);
// Rename the main plugin file.
const oldMain = path.join(targetDir, 'wp-plugins-boilerplate.php');
const newMain = path.join(targetDir, `${pluginName}.php`);
if (await fs.pathExists(oldMain)) {
await fs.rename(oldMain, newMain);
}
// Replace placeholders across every text file.
const files = glob.sync('**/*', {
cwd: targetDir,
nodir: true,
dot: true,
ignore: ['node_modules/**', 'vendor/**'],
});
for (const rel of files) {
const file = path.join(targetDir, rel);
if (!TEXT_EXTENSIONS.has(path.extname(file))) {
continue;
}
const content = await fs.readFile(file, 'utf8');
await fs.writeFile(file, applyReplacements(content));
}
console.log(chalk.green('✅ Files generated and placeholders replaced.'));
console.log(chalk.gray(` Namespace : ${classPrefix}\\`));
console.log(chalk.gray(` Framework : ${classPrefix}\\Framework\\ (scoped on install)`));
console.log(chalk.gray(` Constants : ${constantPrefix}_VERSION, ${constantPrefix}_PATH, ...`));
console.log(chalk.gray(` REST base : /wp-json/${pluginName}/v1`));
console.log(chalk.gray(` WP-CLI : wp ${pluginName} <command>`));
// Remove stale lockfiles so dependencies resolve fresh.
await fs.remove(path.join(targetDir, 'composer.lock'));
// For local framework development, point composer at a path repo.
if (localFrameworkPath) {
const composerPath = path.join(targetDir, 'composer.json');
const composer = await fs.readJson(composerPath);
composer.repositories = [
{ type: 'path', url: path.resolve(localFrameworkPath), options: { symlink: false } },
];
composer['minimum-stability'] = 'dev';
await fs.writeJson(composerPath, composer, { spaces: 2 });
console.log(chalk.gray(` Using local framework: ${path.resolve(localFrameworkPath)}`));
}
// ---- Composer install (pulls + scopes the framework) -------------
try {
console.log(chalk.yellow('\n📦 Installing Composer dependencies...'));
// --no-dev keeps the shipped vendor/ lean; the scoping script runs
// via post-install-cmd and rewrites the bundled framework namespace.
await execPromise('composer install --no-interaction --no-dev --optimize-autoloader', { cwd: targetDir });
console.log(chalk.green('✅ Composer dependencies installed and framework scoped.'));
} catch (error) {
console.error(chalk.red('⚠️ composer install failed.'));
console.error(chalk.gray(error.stderr || error.message || error));
console.log(chalk.yellow(' The wpsuitepress/framework repo must be reachable (or pass --local=/path/to/framework).'));
console.log(chalk.yellow(' Then run "composer install" inside the plugin directory.'));
}
// ---- Frontend assets ---------------------------------------------
try {
console.log(chalk.yellow('\n📦 Installing NPM packages...'));
await execPromise('npm install', { cwd: targetDir });
console.log(chalk.yellow('🏗️ Building production assets...'));
await execPromise('npm run build', { cwd: targetDir });
console.log(chalk.green('✅ Frontend assets built.'));
} catch (err) {
console.error(chalk.red('⚠️ Frontend build failed.'));
console.error(chalk.gray(err.stderr || err.message || err));
}
console.log(chalk.green(`\n🎉 Plugin '${pluginName}' is ready!`));
console.log(chalk.cyan('\nNext steps:'));
console.log(chalk.gray(` cd ${pluginName}`));
console.log(chalk.gray(' npm run dev # Vite dev server with HMR'));
console.log(chalk.gray(' composer install # re-run if the framework repo was offline'));
console.log('');
} catch (err) {
console.error(chalk.red('❌ Error creating plugin:'), err);
process.exit(1);
}
})();