Many WordPress plugins can be ported to EmDash. The plugin model is different—TypeScript instead of PHP, hooks instead of actions/filters, structured storage instead of wp_options—but most functionality maps cleanly.
Portability Assessment
Not all plugins make sense to port. Assess candidates before starting.
Good candidates
Custom fields, SEO plugins, content processors, admin UI extensions, analytics, social sharing, forms
Poor candidates
Multisite features, WooCommerce/Gutenberg integrations, plugins that patch WordPress core internals
Plugin Structure Comparison
WordPress
wp-content/plugins/my-plugin/
├── my-plugin.php # Main file with plugin header
├── includes/
│ ├── class-admin.php
│ └── class-api.php
└── admin/
└── js/ EmDash
my-plugin/
├── src/
│ ├── index.ts # Plugin definition (definePlugin)
│ └── admin.tsx # Admin UI exports (React)
├── package.json
└── tsconfig.json Hooks Mapping
WordPress uses add_action() and add_filter() with string hook names. EmDash uses typed hooks declared in the plugin definition.
Lifecycle Hooks
| WordPress | EmDash | Notes |
|---|---|---|
register_activation_hook() | plugin:install | Runs once on first install |
| Plugin enabled | plugin:activate | Runs when enabled |
| Plugin disabled | plugin:deactivate | Runs when disabled |
register_uninstall_hook() | plugin:uninstall | event.deleteData indicates user choice |
Content Hooks
| WordPress | EmDash | Notes |
|---|---|---|
wp_insert_post_data | content:beforeSave | Return modified content or throw to cancel |
save_post | content:afterSave | Side effects after save |
before_delete_post | content:beforeDelete | Return false to cancel |
deleted_post | content:afterDelete | Cleanup after deletion |
WordPress
add_action('save_post', function($post_id, $post, $update) {
if ($post->post_type !== 'product') return;
$price = get_post_meta($post_id, 'price', true);
if ($price > 1000) {
update_post_meta($post_id, 'is_premium', true);
}
}, 10, 3);
EmDash
hooks: {
"content:afterSave": async (event, ctx) => {
if (event.collection !== "products") return;
const price = event.content.price as number;
if (price > 1000) {
await ctx.kv.set(`premium:${event.content.id}`, true);
}
},
} Media Hooks
| WordPress | EmDash | Notes |
|---|---|---|
wp_handle_upload_prefilter | media:beforeUpload | Validate or transform |
add_attachment | media:afterUpload | React after upload |
Storage Mapping
Options API → KV Store
WordPress
$api_key = get_option('my_plugin_api_key', '');
update_option('my_plugin_api_key', 'abc123');
delete_option('my_plugin_api_key'); EmDash
const apiKey = await ctx.kv.get<string>("settings:apiKey") ?? "";
await ctx.kv.set("settings:apiKey", "abc123");
await ctx.kv.delete("settings:apiKey"); Custom Tables → Storage Collections
WordPress
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_items';
// Insert
$wpdb->insert($table, ['name' => 'Item 1', 'status' => 'active']);
// Query
$items = $wpdb->get_results(
"SELECT \* FROM $table WHERE status = 'active' LIMIT 10"
);
EmDash
// Declare in plugin definition
storage: {
items: {
indexes: ["status", "createdAt"],
},
},
// In hooks or routes:
await ctx.storage.items.put("item-1", {
name: "Item 1",
status: "active",
createdAt: new Date().toISOString(),
});
const result = await ctx.storage.items.query({
where: { status: "active" },
limit: 10,
}); Settings Schema
WordPress uses the Settings API for admin forms. EmDash uses a declarative schema that auto-generates UI.
WordPress
add_action('admin_init', function() {
register_setting('my_plugin', 'my_plugin_api_key');
add_settings_section('main', 'Settings', null, 'my-plugin');
add_settings_field('api_key', 'API Key', function() {
$value = get_option('my_plugin_api_key');
echo '<input type="text" name="my_plugin_api_key"
value="' . esc_attr($value) . '">';
}, 'my-plugin', 'main');
}); EmDash
admin: {
settingsSchema: {
apiKey: {
type: "secret",
label: "API Key",
description: "Your API key from the dashboard",
},
enabled: {
type: "boolean",
label: "Enabled",
default: true,
},
limit: {
type: "number",
label: "Item Limit",
default: 100,
min: 1,
max: 1000,
},
},
} Admin UI
WordPress admin pages are PHP. EmDash uses React components.
import { useState, useEffect } from "react";
export const widgets = {
summary: function SummaryWidget() {
const [count, setCount] = useState(0);
useEffect(() => {
fetch("/_emdash/api/plugins/my-plugin/status")
.then((r) => r.json())
.then((data) => setCount(data.count));
}, []);
return <div>Total items: {count}</div>;
},
};
export const pages = {
settings: function SettingsPage() {
// React component for settings page
return <div>Settings content</div>;
},
};
Register in the plugin definition:
admin: {
entry: "@my-org/my-plugin/admin",
pages: [{ path: "/settings", label: "Dashboard" }],
widgets: [{ id: "summary", title: "Summary", size: "half" }],
},
REST API → Plugin Routes
WordPress
register_rest_route('my-plugin/v1', '/items', [
'methods' => 'GET',
'callback' => function($request) {
global $wpdb;
$items = $wpdb->get_results("SELECT * FROM items LIMIT 50");
return new WP_REST_Response($items);
},
]); EmDash
routes: {
items: {
handler: async (ctx) => {
const result = await ctx.storage.items.query({ limit: 50 });
return { items: result.items };
},
},
}, Routes are available at /_emdash/api/plugins/{plugin-id}/{route-name}.
Porting Process
-
Analyze the WordPress plugin
Document what it does: hooks, database operations, admin pages, REST endpoints.
-
Map to EmDash concepts
WordPress hooks → EmDash hooks.
wp_options→ctx.kv. Custom tables → Storage collections. Admin pages → React components. REST endpoints → Plugin routes. -
Create the plugin skeleton
import { definePlugin } from "emdash"; export function createPlugin() { return definePlugin({ id: "my-ported-plugin", version: "1.0.0", capabilities: [], storage: {}, hooks: {}, routes: {}, admin: {}, }); } -
Implement in order
Storage → Hooks → Admin UI → Routes
-
Test thoroughly
Verify hooks fire correctly, storage works, and admin UI renders.
Example: Read Time Plugin
WordPress
add_filter('wp_insert_post_data', function($data, $postarr) {
if ($data['post_type'] !== 'post') return $data;
$content = strip_tags($data['post_content']);
$word_count = str_word_count($content);
$read_time = ceil($word_count / 200);
if (!empty($postarr['ID'])) {
update_post_meta($postarr['ID'], '_read_time', $read_time);
}
return $data;
}, 10, 2);
EmDash
export function createPlugin() {
return definePlugin({
id: "read-time",
version: "1.0.0",
admin: {
settingsSchema: {
wordsPerMinute: {
type: "number",
label: "Words per minute",
default: 200,
min: 100,
max: 400,
},
},
},
hooks: {
"content:beforeSave": async (event, ctx) => {
if (event.collection !== "posts") return;
const wpm = await ctx.kv.get<number>("settings:wordsPerMinute") ?? 200;
const text = JSON.stringify(event.content.body || "");
const readTime = Math.ceil(text.split(/\s+/).length / wpm);
return { ...event.content, readTime };
},
},
});
} Capabilities
Plugins must declare required capabilities for security sandboxing:
| Capability | Provides | Use Case |
|---|---|---|
network:fetch | ctx.http.fetch() | External API calls |
read:content | ctx.content.get(), list() | Reading CMS content |
write:content | ctx.content.create(), etc. | Modifying content |
read:media | ctx.media.get(), list() | Reading media |
write:media | ctx.media.getUploadUrl() | Uploading media |
Common Gotchas
No global state — Use storage instead of global variables.
Async everything — Always await storage and API calls.
No direct SQL — Use structured storage collections.
No file system — Use the media API for files.
Next Steps
- Hooks Reference — All hooks with signatures
- Storage API — Collections and queries
- Settings — Settings schema and KV store
- Admin UI — Building admin pages