This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,112 @@
---
name: wp-plugin-development
description: "Use when developing WordPress plugins: architecture and hooks, activation/deactivation/uninstall, admin UI and Settings API, data storage, cron/tasks, security (nonces/capabilities/sanitization/escaping), and release packaging."
compatibility: "Targets WordPress 6.9+ (PHP 7.2.24+). Filesystem-based agent with bash + node. Some workflows require WP-CLI."
---
# WP Plugin Development
## When to use
Use this skill for plugin work such as:
- creating or refactoring plugin structure (bootstrap, includes, namespaces/classes)
- adding hooks/actions/filters
- activation/deactivation/uninstall behavior and migrations
- adding settings pages / options / admin UI (Settings API)
- security fixes (nonces, capabilities, sanitization/escaping, SQL safety)
- packaging a release (build artifacts, readme, assets)
## Inputs required
- Repo root + target plugin(s) (path to plugin main file if known).
- Where this plugin runs: single site vs multisite; WP.com conventions if applicable.
- Target WordPress + PHP versions (affects available APIs and placeholder support in `$wpdb->prepare()`).
## Procedure
### 0) Triage and locate plugin entrypoints
1. Run triage:
- `node skills/wp-project-triage/scripts/detect_wp_project.mjs`
2. Detect plugin headers (deterministic scan):
- `node skills/wp-plugin-development/scripts/detect_plugins.mjs`
If this is a full site repo, pick the specific plugin under `wp-content/plugins/` or `mu-plugins/` before changing code.
### 1) Follow a predictable architecture
Guidelines:
- Keep a single bootstrap (main plugin file with header).
- Avoid heavy side effects at file load time; load on hooks.
- Prefer a dedicated loader/class to register hooks.
- Keep admin-only code behind `is_admin()` (or admin hooks) to reduce frontend overhead.
See:
- `references/structure.md`
### 2) Hooks and lifecycle (activation/deactivation/uninstall)
Activation hooks are fragile; follow guardrails:
- register activation/deactivation hooks at top-level, not inside other hooks
- flush rewrite rules only when needed and only after registering CPTs/rules
- uninstall should be explicit and safe (`uninstall.php` or `register_uninstall_hook`)
See:
- `references/lifecycle.md`
### 3) Settings and admin UI (Settings API)
Prefer Settings API for options:
- `register_setting()`, `add_settings_section()`, `add_settings_field()`
- sanitize via `sanitize_callback`
See:
- `references/settings-api.md`
### 4) Security baseline (always)
Before shipping:
- Validate/sanitize input early; escape output late.
- Use nonces to prevent CSRF *and* capability checks for authorization.
- Avoid directly trusting `$_POST` / `$_GET`; use `wp_unslash()` and specific keys.
- Use `$wpdb->prepare()` for SQL; avoid building SQL with string concatenation.
See:
- `references/security.md`
### 5) Data storage, cron, migrations (if needed)
- Prefer options for small config; custom tables only if necessary.
- For cron tasks, ensure idempotency and provide manual run paths (WP-CLI or admin).
- For schema changes, write upgrade routines and store schema version.
See:
- `references/data-and-cron.md`
## Verification
- Plugin activates with no fatals/notices.
- Settings save and read correctly (capability + nonce enforced).
- Uninstall removes intended data (and nothing else).
- Run repo lint/tests (PHPUnit/PHPCS if present) and any JS build steps if the plugin ships assets.
## Failure modes / debugging
- Activation hook not firing:
- hook registered incorrectly (not in main file scope), wrong main file path, or plugin is network-activated
- Settings not saving:
- settings not registered, wrong option group, missing capability, nonce failure
- Security regressions:
- nonce present but missing capability checks; or sanitized input not escaped on output
See:
- `references/debugging.md`
## Escalation
For canonical detail, consult the Plugin Handbook and security guidelines before inventing patterns.
@@ -0,0 +1,19 @@
# Data storage, cron, and upgrades
Use this file when adding persistent storage, background jobs, or upgrade routines.
## Data storage
- Prefer Options API for small config/state.
- Use custom tables only when needed; store schema version and provide upgrade paths.
## Cron
- Ensure tasks are idempotent (may run late or multiple times).
- Provide a manual trigger path for debugging (WP-CLI or admin-only action).
## Database safety note
If using `$wpdb->prepare()`, avoid building queries with concatenated user input.
Recent WordPress versions support identifier placeholders (`%i`) but you must not assume it exists without checking capabilities or target versions.
@@ -0,0 +1,19 @@
# Debugging quick routes
## Plugin doesnt load / fatal errors
- Confirm correct plugin main file and header.
- Check PHP error logs and `WP_DEBUG_LOG`.
- If the repo is a site repo, confirm you edited the correct plugin under `wp-content/plugins/`.
## Activation hook surprises
- Hooks must be registered at top-level.
- Activation runs in a special context; avoid assuming other hooks already ran.
## Settings not saving
- Confirm `register_setting()` is called.
- Confirm the option group matches the form.
- Confirm capability checks and nonces.
@@ -0,0 +1,33 @@
# Activation, deactivation, uninstall
Use this file for lifecycle changes and data cleanup.
## Activation / deactivation hooks
- `register_activation_hook( __FILE__, 'callback' )`
- `register_deactivation_hook( __FILE__, 'callback' )`
Guardrails:
- These hooks must be registered at top-level (not inside other hooks).
- If you flush rewrite rules, ensure rules are registered first (often via a shared function called both on `init` and activation).
Upstream reference:
- https://developer.wordpress.org/plugins/plugin-basics/activation-deactivation-hooks/
## Uninstall
Preferred approaches:
- `uninstall.php` (runs only on uninstall)
- `register_uninstall_hook()`
Guardrails:
- Check `WP_UNINSTALL_PLUGIN` before running destructive cleanup.
Upstream reference:
- https://developer.wordpress.org/plugins/plugin-basics/uninstall-methods/
@@ -0,0 +1,29 @@
# Security guardrails (plugin work)
Use this file when making security fixes or when handling any input/output.
## Nonces + permissions
- Nonces help prevent CSRF, not authorization.
- Always pair nonces with capability checks (`current_user_can()` or a more specific capability).
Upstream reference:
- https://developer.wordpress.org/apis/security/nonces/
## Sanitization and escaping
Golden rule:
- sanitize/validate on input, escape on output.
Practical rules:
- never process the entire `$_POST` / `$_GET` array; read explicit keys
- use `wp_unslash()` before sanitizing when needed
- use prepared statements for SQL; avoid interpolating user input into queries
Common review guidance:
- https://developer.wordpress.org/plugins/wordpress-org/detailed-plugin-guidelines/
@@ -0,0 +1,22 @@
# Settings API (admin options)
Use this file when adding settings pages or storing user-configurable options.
Core APIs:
- `register_setting()`
- `add_settings_section()`
- `add_settings_field()`
Upstream references:
- Settings API overview: https://developer.wordpress.org/plugins/settings/settings-api/
- Register settings: https://developer.wordpress.org/plugins/settings/registration/
- Add settings fields: https://developer.wordpress.org/plugins/settings/settings-fields/
Practical guardrails:
- Use `sanitize_callback` to validate/sanitize data.
- Use capability checks (commonly `manage_options`) for settings screens and saves.
- Escape values on output (`esc_attr`, `esc_html`, etc.).
@@ -0,0 +1,16 @@
# Plugin structure and loading
Use this file when introducing or refactoring a plugin architecture.
## Core concepts
- Main plugin file contains the plugin header and bootstraps the plugin.
- Prefer predictable init:
- minimal boot file
- a loader/class that registers hooks
- admin-only code behind admin hooks
Upstream reference:
- https://developer.wordpress.org/plugins/plugin-basics/
@@ -0,0 +1,122 @@
import fs from "node:fs";
import path from "node:path";
const DEFAULT_IGNORES = new Set([
".git",
"node_modules",
"vendor",
"dist",
"build",
"coverage",
".next",
".turbo",
]);
function statSafe(p) {
try {
return fs.statSync(p);
} catch {
return null;
}
}
function readFileSafe(p, maxBytes = 128 * 1024) {
try {
const buf = fs.readFileSync(p);
if (buf.byteLength > maxBytes) return buf.subarray(0, maxBytes).toString("utf8");
return buf.toString("utf8");
} catch {
return null;
}
}
function findFilesRecursive(repoRoot, predicate, { maxFiles = 6000, maxDepth = 10 } = {}) {
const results = [];
const queue = [{ dir: repoRoot, depth: 0 }];
let visited = 0;
while (queue.length > 0) {
const { dir, depth } = queue.shift();
if (depth > maxDepth) continue;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
for (const ent of entries) {
const fullPath = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (DEFAULT_IGNORES.has(ent.name)) continue;
queue.push({ dir: fullPath, depth: depth + 1 });
continue;
}
if (!ent.isFile()) continue;
visited += 1;
if (visited > maxFiles) return { results, truncated: true };
if (predicate(fullPath)) results.push(fullPath);
}
}
return { results, truncated: false };
}
function parsePluginHeader(contents) {
// WordPress reads plugin headers from the top of the file. We only need key fields.
const header = {};
const pairs = [
["Plugin Name", "name"],
["Plugin URI", "uri"],
["Description", "description"],
["Version", "version"],
["Author", "author"],
["Author URI", "authorUri"],
["Text Domain", "textDomain"],
["Domain Path", "domainPath"],
];
for (const [label, key] of pairs) {
const m = contents.match(new RegExp(`^\\s*${label}:\\s*(.+)\\s*$`, "im"));
if (m) header[key] = m[1].trim();
}
if (!header.name) return null;
return header;
}
function main() {
const repoRoot = process.cwd();
const { results: phpFiles, truncated } = findFilesRecursive(repoRoot, (p) => p.toLowerCase().endsWith(".php"), {
maxFiles: 5000,
maxDepth: 10,
});
const plugins = [];
for (const phpPath of phpFiles) {
const txt = readFileSafe(phpPath);
if (!txt) continue;
if (!/Plugin Name:/i.test(txt)) continue;
const header = parsePluginHeader(txt);
if (!header) continue;
plugins.push({
pluginFile: path.relative(repoRoot, phpPath),
...header,
});
}
const report = {
tool: { name: "detect_plugins", version: "0.1.0" },
repoRoot,
truncated,
count: plugins.length,
plugins,
};
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
}
main();