Good candidates
Custom fields, SEO plugins, content processors, admin UI extensions, analytics, social sharing, forms
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.
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
wp-content/plugins/my-plugin/├── my-plugin.php # Main file with plugin header├── includes/│ ├── class-admin.php│ └── class-api.php└── admin/ └── js/my-plugin/├── src/│ ├── index.ts # Plugin definition (definePlugin)│ └── admin.tsx # Admin UI exports (React)├── package.json└── tsconfig.jsonWordPress uses add_action() and add_filter() with string hook names. EmDash uses typed hooks declared in the plugin definition.
| 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 |
| 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 |
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);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); } },}| WordPress | EmDash | Notes |
|---|---|---|
wp_handle_upload_prefilter | media:beforeUpload | Validate or transform |
add_attachment | media:afterUpload | React after upload |
$api_key = get_option('my_plugin_api_key', '');update_option('my_plugin_api_key', 'abc123');delete_option('my_plugin_api_key');const apiKey = await ctx.kv.get<string>("settings:apiKey") ?? "";await ctx.kv.set("settings:apiKey", "abc123");await ctx.kv.delete("settings:apiKey");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");// Declare in plugin definitionstorage: { 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,});WordPress uses the Settings API for admin forms. EmDash uses a declarative schema that auto-generates UI.
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');});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, }, },}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" }],},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); },]);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}.
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.
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);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 }; }, }, });}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 |
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.