@@ -708,7 +708,7 @@ namespace Bones {
708708 define ('WPBONES_MINIMAL_PHP_VERSION ' , '7.4 ' );
709709
710710 /* MARK: The WP Bones command line version. */
711- define ('WPBONES_COMMAND_LINE_VERSION ' , '1.11.1 ' );
711+ define ('WPBONES_COMMAND_LINE_VERSION ' , '2.0.0 ' );
712712
713713 use Bones \SemVer \Exceptions \InvalidVersionException ;
714714 use Bones \SemVer \Version ;
@@ -1315,9 +1315,11 @@ namespace Bones {
13151315 $ this ->line (' version Update the Plugin version ' );
13161316 $ this ->info ('migrate ' );
13171317 $ this ->line (' migrate:create Create a new Migration ' );
1318+ $ this ->line (' migrate:to-v2 Migrate gulp-based plugin to v2 webpack infrastructure ' );
13181319 $ this ->info ('make ' );
13191320 $ this ->line (' make:ajax Create a new Ajax service provider class ' );
13201321 $ this ->line (' make:api Create a new API controller class ' );
1322+ $ this ->line (' make:app Create a new React/TS app in resources/assets/apps ' );
13211323 $ this ->line (' make:console Create a new Bones command ' );
13221324 $ this ->line (' make:controller Create a new controller class ' );
13231325 $ this ->line (' make:cpt Create a new Custom Post Type service provider class ' );
@@ -1374,6 +1376,10 @@ namespace Bones {
13741376 elseif ($ this ->isCommand ('migrate:create ' )) {
13751377 $ this ->createMigrate ($ this ->getCommandParams (0 ));
13761378 }
1379+ // migrate:to-v2
1380+ elseif ($ this ->isCommand ('migrate:to-v2 ' )) {
1381+ $ this ->migrateToV2 ();
1382+ }
13771383 // -- make ---------------------------------------------
13781384 //
13791385 // make:ajax {className}
@@ -1384,6 +1390,10 @@ namespace Bones {
13841390 elseif ($ this ->isCommand ('make:api ' )) {
13851391 $ this ->createAPIController ($ this ->getCommandParams (0 ));
13861392 }
1393+ // make:app {appName}
1394+ elseif ($ this ->isCommand ('make:app ' )) {
1395+ $ this ->createApp ($ this ->getCommandParams (0 ));
1396+ }
13871397 // make:console {command_name}
13881398 elseif ($ this ->isCommand ('make:console ' )) {
13891399 $ this ->createCommand ($ this ->getCommandParams (0 ));
@@ -3161,6 +3171,250 @@ namespace Bones {
31613171 $ this ->optimize ();
31623172 }
31633173
3174+ /**
3175+ * Scaffold a React/TS app under resources/assets/apps.
3176+ *
3177+ * Default (folder-based, best for apps with sub-components):
3178+ * resources/assets/apps/<name>/index.tsx
3179+ *
3180+ * With --flat (single-file, best for tiny apps):
3181+ * resources/assets/apps/<name>.tsx
3182+ *
3183+ * The entry is auto-discovered by the v2 webpack config — no package.json edits needed.
3184+ *
3185+ * @param string|null $appName Lowercase name, dashes allowed (e.g. "dashboard", "billing-widget").
3186+ */
3187+ protected function createApp (?string $ appName = '' )
3188+ {
3189+ if ($ this ->isHelp ($ appName )) {
3190+ $ this ->info ('Usage: ' );
3191+ $ this ->line (' php bones make:app <name> [--flat] ' );
3192+ $ this ->info ('Options: ' );
3193+ $ this ->line (' --flat Create a single-file app (apps/<name>.tsx) instead of folder-based ' );
3194+ return ;
3195+ }
3196+
3197+ if (empty ($ appName )) {
3198+ $ appName = $ this ->ask ('App name (lowercase, e.g. "dashboard") ' );
3199+ }
3200+
3201+ if (!preg_match ('/^[a-z][a-z0-9-]*$/ ' , $ appName )) {
3202+ $ this ->error ('Invalid app name. Use lowercase letters, digits, and dashes (must start with a letter). ' );
3203+ return ;
3204+ }
3205+
3206+ // Names that collide with scripts WordPress core registers. If we let
3207+ // the user pick one of these, wp_enqueue_script silently skips our
3208+ // bundle because the handle is already taken and the app never mounts
3209+ // in the browser. Block the name up-front instead of letting the user
3210+ // debug a ghost bug.
3211+ $ reservedHandles = [
3212+ 'dashboard ' , 'post ' , 'postbox ' , 'common ' , 'user ' , 'user-profile ' ,
3213+ 'utils ' , 'admin-bar ' , 'admin-comments ' , 'media-upload ' , 'media-views ' ,
3214+ 'jquery ' , 'jquery-core ' , 'jquery-ui-core ' , 'backbone ' , 'underscore ' ,
3215+ 'react ' , 'react-dom ' , 'react-jsx-runtime ' ,
3216+ 'wp-api ' , 'wp-element ' , 'wp-components ' , 'wp-data ' , 'wp-hooks ' , 'wp-i18n ' ,
3217+ 'wp-util ' , 'wp-a11y ' , 'wp-date ' ,
3218+ ];
3219+ if (in_array ($ appName , $ reservedHandles , true )) {
3220+ $ this ->error ("' {$ appName }' collides with a reserved WordPress script handle — wp_enqueue_script would silently drop your bundle. " );
3221+ $ this ->line (" Pick a different name, e.g. {$ appName }-app, my- {$ appName }, or a plugin-specific prefix. " );
3222+ return ;
3223+ }
3224+
3225+ $ flat = in_array ('--flat ' , $ this ->arguments (), true );
3226+
3227+ // PascalCase component name (dashboard-widget → DashboardWidget)
3228+ $ componentName = str_replace (' ' , '' , ucwords (str_replace ('- ' , ' ' , $ appName )));
3229+
3230+ // Resolve text domain from plugin header, fall back to a placeholder the user can replace.
3231+ $ header = $ this ->extractPluginHeaderInfo (['Text Domain ' ]);
3232+ $ textDomain = $ header ['Text Domain ' ] ?? 'your-text-domain ' ;
3233+
3234+ $ content = $ this ->prepareStub ('app ' , [
3235+ '{AppName} ' => $ appName ,
3236+ '{ComponentName} ' => $ componentName ,
3237+ '{TextDomain} ' => $ textDomain ,
3238+ ]);
3239+
3240+ $ this ->mkdirIfNotExists ('resources/assets/apps ' );
3241+
3242+ if ($ flat ) {
3243+ $ filepath = "resources/assets/apps/ {$ appName }.tsx " ;
3244+ if (file_exists ($ filepath )) {
3245+ $ this ->error ("File already exists: {$ filepath }" );
3246+ return ;
3247+ }
3248+ file_put_contents ($ filepath , $ content );
3249+ $ this ->line (" Created {$ filepath }" );
3250+ } else {
3251+ $ folder = "resources/assets/apps/ {$ appName }" ;
3252+ if (file_exists ($ folder )) {
3253+ $ this ->error ("Folder already exists: {$ folder }" );
3254+ return ;
3255+ }
3256+ mkdir ($ folder , 0755 , true );
3257+ $ filepath = "{$ folder }/index.tsx " ;
3258+ file_put_contents ($ filepath , $ content );
3259+ $ this ->line (" Created {$ filepath }" );
3260+ }
3261+
3262+ $ this ->info ("\nNext steps: " );
3263+ $ this ->line (" 1. Add the mount point to your view: " );
3264+ $ this ->line (" <div id= \"{$ appName }-root \"></div> " );
3265+ $ this ->line (" 2. Enqueue it from your controller: " );
3266+ $ this ->line (" ->withAdminAppsScript(' {$ appName }') " );
3267+ $ this ->line (" 3. Run yarn dev — webpack auto-discovers the new entry. " );
3268+ }
3269+
3270+ /**
3271+ * Migrate a v1.x plugin (gulp + run-s + wp-scripts split) to the v2 unified
3272+ * webpack infrastructure.
3273+ *
3274+ * The migration:
3275+ * - Deletes gulpfile.js and package-lock.json (switching to yarn)
3276+ * - Creates webpack.config.js, tsconfig.json, .prettierrc, jest.config.js
3277+ * - Rewrites package.json scripts to the unified dev/build/test/format block
3278+ * - Drops gulp-* and npm-run-all devDependencies
3279+ * - Adds the v2 devDependency set (@wordpress/scripts 31+, typescript, glob,
3280+ * less/less-loader, webpack-remove-empty-scripts, @wordpress/jest-preset-default,
3281+ * @types/react, @types/react-dom)
3282+ *
3283+ * The migration does NOT touch resources/assets/ — the developer's code stays
3284+ * as-is. After the migration, run `yarn install && yarn build` to verify.
3285+ *
3286+ * @since 2.0.0
3287+ */
3288+ protected function migrateToV2 (): void
3289+ {
3290+ $ this ->info ('WP Bones — migrate to v2 ' );
3291+ $ this ->line ('' );
3292+ $ this ->warning ('This will modify your plugin build infrastructure: ' );
3293+ $ this ->line (' • Delete gulpfile.js and package-lock.json (switching to yarn) ' );
3294+ $ this ->line (' • Create webpack.config.js, tsconfig.json, .prettierrc, jest.config.js ' );
3295+ $ this ->line (' • Rewrite package.json scripts and devDependencies ' );
3296+ $ this ->line ('' );
3297+ $ this ->warning ('Commit your current work first. The resources/assets/ folder is left untouched. ' );
3298+ $ this ->line ('' );
3299+
3300+ $ answer = $ this ->ask ('Continue? (y/N) ' );
3301+ if (strtolower (trim ($ answer )) !== 'y ' ) {
3302+ $ this ->line ('Migration aborted. ' );
3303+ return ;
3304+ }
3305+
3306+ // 1. Delete gulp-era files
3307+ foreach (['gulpfile.js ' , 'package-lock.json ' ] as $ file ) {
3308+ if (file_exists ($ file )) {
3309+ unlink ($ file );
3310+ $ this ->line (" Removed {$ file }" );
3311+ }
3312+ }
3313+
3314+ // 2. Create v2 config files (skip if already present — don't clobber customizations)
3315+ $ configs = [
3316+ 'webpack.config.js ' => 'webpack-config ' ,
3317+ 'tsconfig.json ' => 'tsconfig ' ,
3318+ '.prettierrc ' => 'prettierrc ' ,
3319+ 'jest.config.js ' => 'jest-config ' ,
3320+ ];
3321+ foreach ($ configs as $ file => $ stub ) {
3322+ if (file_exists ($ file )) {
3323+ $ this ->warning (" Kept existing {$ file } (review manually) " );
3324+ continue ;
3325+ }
3326+ file_put_contents ($ file , $ this ->prepareStub ($ stub , []));
3327+ $ this ->line (" Created {$ file }" );
3328+ }
3329+
3330+ // 3. Rewrite package.json
3331+ if (file_exists ('package.json ' )) {
3332+ $ pkg = json_decode (file_get_contents ('package.json ' ), true );
3333+ if (!is_array ($ pkg )) {
3334+ $ this ->error ('package.json is not valid JSON, skipping rewrite. ' );
3335+ } else {
3336+ $ previousScripts = $ pkg ['scripts ' ] ?? [];
3337+
3338+ $ pkg ['scripts ' ] = [
3339+ 'dev ' => 'wp-scripts start ' ,
3340+ 'build ' => 'wp-scripts build ' ,
3341+ 'test ' => 'wp-scripts test-unit-js ' ,
3342+ 'test:watch ' => 'wp-scripts test-unit-js --watch ' ,
3343+ 'format ' => 'wp-scripts format ' ,
3344+ 'format:check ' => 'wp-scripts format --check ' ,
3345+ 'lint ' => 'wp-scripts lint-js resources/ ' ,
3346+ 'lint:style ' => "wp-scripts lint-style 'resources/**/*.{css,scss}' " ,
3347+ 'check-engines ' => 'wp-scripts check-engines ' ,
3348+ 'check-licenses ' => 'wp-scripts check-licenses ' ,
3349+ 'packages-update ' => 'wp-scripts packages-update ' ,
3350+ ];
3351+
3352+ // Preserve make-pot / make-json if the plugin had them
3353+ foreach (['make-pot ' , 'make-json ' ] as $ preserve ) {
3354+ if (isset ($ previousScripts [$ preserve ])) {
3355+ $ pkg ['scripts ' ][$ preserve ] = $ previousScripts [$ preserve ];
3356+ }
3357+ }
3358+
3359+ // Drop gulp-era devDeps
3360+ $ dropDevDeps = [
3361+ '@babel/core ' , '@babel/preset-env ' , '@babel/preset-react ' ,
3362+ 'gulp ' , 'gulp-babel ' , 'gulp-clean-css ' , 'gulp-less ' ,
3363+ 'gulp-sass ' , 'gulp-typescript ' , 'gulp-uglify ' , 'gulp-watch ' ,
3364+ 'sass ' , 'npm-run-all ' ,
3365+ ];
3366+ foreach ($ dropDevDeps as $ dep ) {
3367+ unset($ pkg ['devDependencies ' ][$ dep ]);
3368+ }
3369+
3370+ // v2-required devDeps — always overwritten so an old pinned version
3371+ // (e.g. @wordpress/scripts ^27) is bumped to the range v2 needs.
3372+ $ addDevDeps = [
3373+ '@types/react ' => '^18.3.0 ' ,
3374+ '@types/react-dom ' => '^18.3.0 ' ,
3375+ '@wordpress/jest-preset-default ' => '^12.44.0 ' ,
3376+ '@wordpress/scripts ' => '^31.7.0 ' ,
3377+ 'glob ' => '^11.0.0 ' ,
3378+ 'less ' => '^4.6.4 ' ,
3379+ 'less-loader ' => '^12.2.0 ' ,
3380+ 'typescript ' => '^5.9.3 ' ,
3381+ 'webpack-remove-empty-scripts ' => '^1.1.0 ' ,
3382+ ];
3383+ foreach ($ addDevDeps as $ dep => $ version ) {
3384+ $ pkg ['devDependencies ' ][$ dep ] = $ version ;
3385+ }
3386+
3387+ if (isset ($ pkg ['devDependencies ' ])) {
3388+ ksort ($ pkg ['devDependencies ' ]);
3389+ }
3390+ if (isset ($ pkg ['dependencies ' ])) {
3391+ ksort ($ pkg ['dependencies ' ]);
3392+ }
3393+
3394+ file_put_contents (
3395+ 'package.json ' ,
3396+ json_encode ($ pkg , JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) . "\n"
3397+ );
3398+ $ this ->line (' Rewrote package.json ' );
3399+ }
3400+ }
3401+
3402+ // 4. Summary + manual steps
3403+ $ this ->line ('' );
3404+ $ this ->success ('✅ Migration to v2 complete. ' );
3405+ $ this ->line ('' );
3406+ $ this ->info ('Next steps: ' );
3407+ $ this ->line (' 1. yarn install ' );
3408+ $ this ->line (' 2. yarn build # verify everything compiles ' );
3409+ $ this ->line (' 3. yarn test # verify tests still pass, if any ' );
3410+ $ this ->line ('' );
3411+ $ this ->warning ('Review manually: ' );
3412+ $ this ->line (' • Custom gulp tasks (if you had any) must be re-implemented as webpack plugins ' );
3413+ $ this ->line (' • Old build:<name> / start:<name> scripts for individual apps are gone — ' );
3414+ $ this ->line (' webpack auto-discovers everything under resources/assets/apps/. ' );
3415+ $ this ->line (' • File extensions: .jsx/.tsx files in apps/ are fine; plain .js with JSX must be renamed. ' );
3416+ }
3417+
31643418 /**
31653419 * Let's roll
31663420 *
0 commit comments