--- name: wordpress-plugin-rebrand description: Use when forking and rebranding an existing WordPress plugin under a new name/prefix (e.g. for a white-label or iWP-branded resale product). Covers identifier renaming, repo flattening, updater bundling, and license-field wiring. --- # WordPress Plugin Rebrand Process for taking an existing WordPress plugin and rebranding it under a new name/prefix, without breaking anything or missing a spot. Validated live across 5 real plugins in one session (WooList, WooBC, WooSol, SlickWoo, MetAI → iWP-branded forks), each independently re-verified end-to-end afterward with zero defects found. ## Inputs you need before starting - Source repo (clone URL + credentials). - New brand prefix (e.g. `iWP`), new product slug (e.g. `iwp-woolist`) — **get the exact slug from wherever the catalog/product list already defines it, never invent your own.** - The updater/license-client file to bundle, if the target platform has one (e.g. a shared `IWP_Updater` class), and its exact integration snippet (api_url, plugin_slug, license_option names). ## Step 1 — Learn the actual existing convention, don't assume Read the plugin's main file first. Every plugin has its own prefix scheme — don't assume it matches a previous rebrand. Identify: - Constant prefix (`PLUGINNAME_...`) - Class prefix (`PluginName_...`) - Function prefix (`pluginname_...`) - Text domain string (usually lowercase-hyphenated, e.g. `pluginname`) - Whether the plugin lives at the repo root or in a subdirectory ## Step 2 — Flatten to repo root If the plugin's files live in a subdirectory (not the repo root), move everything up to root. Reason: many git hosts' repo-archive endpoint (`/archive/{tag}.zip`-style) names the zip's top-level folder after the **repo**, so a nested subdirectory inside the repo produces a double-nested folder in the resulting distributable zip (`reponame/plugin-subdir/...` instead of `reponame/...`). Flattening avoids this categorically rather than requiring special-case handling downstream. ## Step 3 — Rename, in this order 1. **Text-domain / hyphenated contexts first**: every `oldname-` (CSS classes, script/style handles, asset file paths, admin page slugs) → `newprefix-oldname-`. A safe global find/replace, catches the vast majority of hyphenated identifiers correctly in one pass. 2. **Constants**: `OLDNAME_` → `NEWPREFIX_OLDNAME_`. 3. **Classes**: `OldName_` → `NewPrefix_OldName_`. 4. **Functions / hooks / underscore contexts**: `oldname_` → `newprefix_oldname_`. 5. **camelCase-after-underscore contexts** (JS localize-object names are the common case: `oldnameData` used as the 2nd arg to `wp_localize_script()`) → `newprefix_oldnameData`. **Grep the JS files for the exact existing spelling before finalizing — don't guess the casing, the PHP `wp_localize_script()` call must match byte-for-byte or the JS breaks silently at runtime.** 6. **Remaining bare prose occurrences of the product name** (page titles, UI labels, doc-comments) → the new display name (e.g. `MetAI` → `iWP MetAI`). Do this LAST, after class renaming, or you'll double- prefix the class name. 7. **File/directory renames** to match: `class-oldname-admin.php` → `class-newprefix-oldname-admin.php`, etc. 8. Update the plugin header block: `Plugin Name`, `Author`, `Author URI`, `Plugin URI`, `Text Domain` (always hyphenated, never underscored — WordPress text domains are always hyphen-form even when everything else in that context uses underscores). ## Step 4 — Cross-check enqueue calls against actual filenames Every `wp_enqueue_script()`, `wp_enqueue_style()`, `wp_localize_script()` handle/path argument must match the ACTUAL renamed filenames on disk exactly, or the admin UI silently fails to load its JS/CSS with no error message anywhere obvious. Check this explicitly, don't assume the renames were consistent. ## Step 5 — Bundle the updater / license client (if applicable) Copy the shared updater class unchanged into the plugin's `includes/` directory. **Where you wire it in depends on the plugin's own architecture — get this wrong and license/update-checking silently never runs, with no error anywhere** (see the full incident writeup in `wordpress-plugin-conventions`'s "Never add_action at a lower priority from inside a callback on the same hook" section): - **If the plugin is flat/procedural** (no singleton class hooked to `plugins_loaded` itself — the 5 original iWP forks are this shape), a top-level call in the main file is safe: ```php require_once . 'includes/class-.php'; add_action( 'plugins_loaded', function () { new ( [ /* ... */ ] ); }, 5 ); ``` This works because the file itself loads (and this `add_action` call runs) BEFORE `plugins_loaded` ever fires — it's not nested inside another callback already running on that same hook. - **If the plugin uses a singleton class already hooked to `add_action('plugins_loaded', ['ClassName', 'instance'])`** (the iWP Cache / iWP Elementor Addons / iWP Image Optimize shape, and likely InformatiQ Toolkit's shape too — check for a `public static function instance()` pattern), do **NOT** add a nested `add_action('plugins_loaded', ..., 5)` inside that class's constructor — confirmed dead code in exactly this shape, three times, in one session. Instead call it **directly, immediately**, inside the constructor: ```php private function __construct() { new IWP_Updater( [ /* ... */ ] ); // direct call, no nested add_action // ...rest of the constructor's hook registrations... } ``` `IWP_Updater`'s own constructor only registers filters on OTHER hooks (`pre_set_site_transient_update_plugins`, `plugins_api`, `upgrader_process_complete`) — never `plugins_loaded` itself — so there is no ordering reason to defer construction at all. Then add ONE settings field for the license key (option name matching `license_option` above) into wherever the plugin already has an admin settings UI — follow that file's existing code style/pattern for how other fields are registered/rendered, don't introduce a different pattern. If the plugin has no settings UI at all, add a minimal one rather than skipping this step — license-gated updates need somewhere for the customer to enter the key. ## Step 6 — Verify before pushing 1. `php -l` every `.php` file. Zero tolerance for syntax errors. 2. Case-insensitive grep the whole tree for the bare old name: `grep -rniE '(^|[^a-z_-])oldname' --include='*.php' --include='*.js' --include='*.css' .` — every hit must be part of a correctly-prefixed token (`newprefix-oldname`, `newprefix_oldname`, `NewPrefix_OldName`, or the new display name). Zero unprefixed exceptions, except possibly an original-authorship credit comment if one genuinely exists. 3. Sanity-check for double-prefixing bugs (`iWP iWP`, `IWP_IWP`, `iwp-iwp`, `iwp_iwp` or equivalent for your prefix) — a real mistake that happens when step 3.6 runs before 3.3, or runs twice. ## Step 7 — Ship Commit, push (force-push is fine for a freshly-created fork with no other history depending on it — confirm that's actually the case before force-pushing anything else). Tag a release. See the `gitea-release-workflow` skill for the release/verification steps if the git host is Gitea. ## Common failure modes (all hit at least once during validation) - **Forgetting the flatten-to-root step** → double-nested zip. - **JS localize-object name mismatch** (hyphen where an underscore is required for a valid JS identifier) → admin UI JS silently breaks with no visible error. - **Trusting the delegate's self-report instead of re-verifying** — every single delegate run in the validating session initially reported success; independent re-verification (re-running `php -l` yourself, a fresh grep, an actual functional test through the real serving pipeline) is what actually confirms it, not the report.