Skip to content

Port WordPress Plugins

Port a WordPress plugin by separating its content behavior, stored data, HTTP routes, and user interface. Then choose the EmDash plugin format that supports those requirements.

Check whether the plugin belongs in EmDash

Section titled “Check whether the plugin belongs in EmDash”

Good candidates own behavior that is independent of WordPress core, such as content validation, external API calls, background processing, custom stored records, settings, or an admin tool.

Do not port a plugin whose only purpose is to implement a WordPress concern that Astro or EmDash already replaces. Examples include PHP page caching, WordPress rewrite rules, theme template selection, or modifications to WordPress core globals.

For a plugin that defines a custom post type or fields but has little runtime behavior, create an EmDash collection and seed file instead of a plugin.

Start with Choosing a plugin format. The two formats share hook names and the PluginContext APIs, but their source packages are different.

Requirement Sandboxed Native
Registry installation Yes No
Isolated runtime Yes, with a configured runner No
Hooks, routes, KV, structured storage Yes Yes
Block Kit admin pages Yes Yes
Custom React admin components No Yes
Astro components for public rendering No Yes
Raw page fragments No Yes

Choose native only when the port needs a native-only build-time or user-interface surface.

emdash-plugin init creates the current sandboxed format:

Sandboxed plugin
my-plugin/
├── emdash-plugin.jsonc
├── src/
│ └── plugin.ts
├── tests/
│ └── plugin.test.ts
├── package.json
└── tsconfig.json

The manifest contains identity, publisher, capabilities, allowed hosts, and storage declarations. Version normally comes from package.json.

The following manifest declares one indexed storage collection and the capability required by content:afterSave:

emdash-plugin.jsonc
{
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
"slug": "read-time",
"publisher": "did:plc:abc123def456",
"license": "MIT",
"author": { "name": "Example Author" },
"security": { "email": "security@example.com" },
"capabilities": ["content:read"],
"allowedHosts": [],
"storage": {
"calculations": { "indexes": ["contentId", "updatedAt"] }
}
}

src/plugin.ts default-exports a plain object typed with SandboxedPlugin. Sandboxed hook handlers use { handler }; sandboxed route handlers receive (routeCtx, ctx):

src/plugin.ts
import type { SandboxedPlugin } from "emdash/plugin";
export default {
hooks: {
"content:afterSave": {
handler: async (event, ctx) => {
await ctx.storage.calculations.put(event.content.id, {
contentId: event.content.id,
updatedAt: new Date().toISOString(),
});
},
},
},
routes: {
recent: {
handler: async (_routeCtx, ctx) => {
const result = await ctx.storage.calculations.query({
orderBy: { updatedAt: "desc" },
limit: 10,
});
return { items: result.items };
},
},
},
} satisfies SandboxedPlugin;

The route is available at /_emdash/api/plugins/read-time/recent. A storage field must be declared as an index before a query can filter or sort by it.

Build the package with emdash-plugin build; do not add a hand-written src/index.ts descriptor to this format. Read Your first sandboxed plugin for the generated package.json, build output, and site registration.

A native package exports both a descriptor factory for astro.config.mjs and a runtime factory built with definePlugin(). Optional admin and Astro entrypoints are separate package exports.

Native plugin
my-native-plugin/
├── src/
│ ├── index.ts
│ ├── admin.tsx
│ └── astro/
│ └── index.ts
├── package.json
└── tsconfig.json

The following reduced native entrypoint shows the two required pieces:

src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export interface ReadTimeOptions {
wordsPerMinute?: number;
}
export function readTimePlugin(options: ReadTimeOptions = {}): PluginDescriptor {
return {
id: "read-time",
version: "0.1.0",
format: "native",
entrypoint: "@example/plugin-read-time",
capabilities: ["content:read"],
options,
};
}
export function createPlugin(options: ReadTimeOptions = {}) {
return definePlugin({
id: "read-time",
version: "0.1.0",
capabilities: ["content:read"],
admin: {
settingsSchema: {
wordsPerMinute: {
type: "number",
label: "Words per minute",
default: options.wordsPerMinute ?? 200,
min: 1,
},
},
},
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
});
}
export default createPlugin;

Native hook handlers can be functions directly. Native route handlers receive one combined context argument. Keep the descriptor and runtime copies of id, version, capabilities, and entrypoints aligned.

Read Your first native plugin before adding React admin pages, Portable Text renderers, or page fragments.

Map the intent of a WordPress action or filter, not only its name:

WordPress EmDash
register_activation_hook() plugin:install for first installation, or plugin:activate for enablement
register_uninstall_hook() plugin:uninstall
wp_insert_post_data content:beforeSave
save_post content:afterSave
before_delete_post content:beforeDelete
deleted_post content:afterDelete
wp_handle_upload_prefilter media:beforeUpload
add_attachment media:afterUpload

Hook events have their own typed shapes. Check the hook reference before translating WordPress callback arguments.

Content hooks that receive entry data require content:read. Add capabilities according to the APIs the port calls:

Capability API made available
content:read Read content and register content hooks that expose entry data
content:write Create, update, publish, or delete content; also implies content read access
media:read Read media records
media:write Create or update media; also implies media read access
network:request Use ctx.http for the hosts listed in allowedHosts

Use ctx.settings for user configuration and ctx.kv for small internal values. Both stores are isolated by plugin. Declare credentials as secret fields in admin.settingsSchema so EmDash encrypts them.

Use declared ctx.storage.<collection> collections for queryable plugin records. The storage declaration belongs in emdash-plugin.jsonc for a sandboxed plugin and in definePlugin() for a native plugin. Do not open the EmDash database or interpolate SQL from plugin code.

The following comparison ports one option value without exposing WordPress globals to the new plugin:

$api_key = get_option('read_time_api_key', '');
update_option('read_time_api_key', $new_api_key);

For a WordPress custom table, identify the fields used for filtering and ordering before declaring storage. The following sandboxed manifest fragment indexes both fields used by the query:

"storage": {
"jobs": { "indexes": ["status", "createdAt"] }
}

The runtime can then store and query job records:

await ctx.storage.jobs.put("job-123", {
status: "pending",
createdAt: new Date().toISOString(),
});
const pending = await ctx.storage.jobs.query({
where: { status: "pending" },
orderBy: { createdAt: "asc" },
limit: 50,
});

Declare both status and createdAt as indexes in the manifest or native storage definition before running that query.

Map a WordPress REST route to a plugin route. EmDash mounts it at /_emdash/api/plugins/<plugin-id>/<route-name>. Define an inputSchema when the route accepts input, and return JSON-serializable data.

Sandboxed plugins describe admin pages with Block Kit and read or write values through routes and KV. They do not ship React into the admin application.

Native plugins can use admin.settingsSchema for a generated form. Use an adminEntry package export for custom React pages, widgets, field widgets, or list columns.

Use the media APIs for uploaded or generated files. Sandboxed plugins do not have filesystem access. Native plugins share the host process, but writing deployment-local files is not a portable storage strategy.

  1. Inventory WordPress hooks, options, custom tables, cron jobs, REST routes, admin pages, blocks, shortcodes, and external hosts.

  2. Remove behavior that belongs to Astro routing, the EmDash content model, or the deployment platform.

  3. Choose the sandboxed or native package format. Record every capability and allowed host the remaining behavior needs.

  4. Define KV keys and structured storage collections. Add indexes for every field used by where or orderBy.

  5. Port one observable behavior at a time. Test the hook or route with representative content and failure cases.

  6. Add Block Kit or native admin UI only after the underlying routes and storage behavior work.

  7. Test installation, upgrade, activation, deactivation, uninstall with and without data deletion, and capability changes.