Skip to content

Porting WordPress Plugins

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/

WordPress uses add_action() and add_filter() with string hook names. EmDash uses typed hooks declared in the plugin definition.

WordPressEmDashNotes
register_activation_hook()plugin:installRuns once on first install
Plugin enabledplugin:activateRuns when enabled
Plugin disabledplugin:deactivateRuns when disabled
register_uninstall_hook()plugin:uninstallevent.deleteData indicates user choice
WordPressEmDashNotes
wp_insert_post_datacontent:beforeSaveReturn modified content or throw to cancel
save_postcontent:afterSaveSide effects after save
before_delete_postcontent:beforeDeleteReturn false to cancel
deleted_postcontent:afterDeleteCleanup 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);
WordPressEmDashNotes
wp_handle_upload_prefiltermedia:beforeUploadValidate or transform
add_attachmentmedia:afterUploadReact after upload
$api_key = get_option('my_plugin_api_key', '');
update_option('my_plugin_api_key', 'abc123');
delete_option('my_plugin_api_key');
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"
);

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');
});

WordPress admin pages are PHP. EmDash uses React components.

src/admin.tsx
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:

src/index.ts
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 are available at /_emdash/api/plugins/{plugin-id}/{route-name}.

  1. Analyze the WordPress plugin

    Document what it does: hooks, database operations, admin pages, REST endpoints.

  2. Map to EmDash concepts

    WordPress hooks → EmDash hooks. wp_optionsctx.kv. Custom tables → Storage collections. Admin pages → React components. REST endpoints → Plugin routes.

  3. Create the plugin skeleton

    src/index.ts
    import { definePlugin } from "emdash";
    export function createPlugin() {
    return definePlugin({
    id: "my-ported-plugin",
    version: "1.0.0",
    capabilities: [],
    storage: {},
    hooks: {},
    routes: {},
    admin: {},
    });
    }
  4. Implement in order

    Storage → Hooks → Admin UI → Routes

  5. 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);

Plugins must declare required capabilities for security sandboxing:

CapabilityProvidesUse Case
network:fetchctx.http.fetch()External API calls
read:contentctx.content.get(), list()Reading CMS content
write:contentctx.content.create(), etc.Modifying content
read:mediactx.media.get(), list()Reading media
write:mediactx.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.