Porting WordPress Plugins

On this page

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

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

Content Hooks

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

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

WordPressEmDashNotes
wp_handle_upload_prefiltermedia:beforeUploadValidate or transform
add_attachmentmedia:afterUploadReact 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

  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

    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.

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:

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

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