wordpress-plugin-to-emdash
$
npx mdskill add emdash-cms/emdash/wordpress-plugin-to-emdashThis skill maps WordPress concepts to their EmDash equivalents for plugin porting. For general plugin authoring details (plugin structure, `definePlugin()`, hooks, storage, admin UI, etc.), use the **creating-plugins** skill.
SKILL.md
.github/skills/wordpress-plugin-to-emdashView on GitHub ↗
---
name: wordpress-plugin-to-emdash
description: Port a WordPress plugin to EmDash CMS. Use this skill when asked to migrate, convert, or port a WordPress plugin, theme functionality, or custom post type to EmDash. Provides concept mapping and implementation patterns.
---
# Porting WordPress Plugins to EmDash
This skill maps WordPress concepts to their EmDash equivalents for plugin porting. For general plugin authoring details (plugin structure, `definePlugin()`, hooks, storage, admin UI, etc.), use the **creating-plugins** skill.
## Migration Approach
1. **Understand the plugin** — What does it do, not how
2. **Identify concepts** — Content types, admin pages, hooks, shortcodes
3. **Map to EmDash** — Use the tables below
4. **Implement in TypeScript** — Clean room, not line-by-line port. Use the **creating-plugins** skill for implementation details.
5. **Test behaviour** — Same result, different implementation
## Concept Mapping
### Content & Data
| WordPress | EmDash | Notes |
| ----------------------- | ----------------------------------------- | --------------------------------------------- |
| `register_post_type()` | `SchemaRegistry.createCollection()` | Via Admin API or seed file |
| `register_taxonomy()` | `_emdash_taxonomy_defs` table | Hierarchical or flat, attached to collections |
| `register_meta()` / ACF | Collection fields via SchemaRegistry | All become typed schema fields |
| `get_post_meta()` | `entry.data.fieldName` | Direct typed access |
| `get_option()` | `getSiteSetting()` / `ctx.kv` | Site settings or plugin-namespaced KV |
| `WP_Query` | `getEmDashCollection()` | Runtime queries with filters |
| `get_post($id)` | `getEmDashEntry(collection, slug)` | Returns entry or null |
| `wp_insert_post()` | `POST /_emdash/api/content/{type}` | REST API |
| `wp_update_post()` | `PUT /_emdash/api/content/{type}/{id}` | REST API |
| `wp_delete_post()` | `DELETE /_emdash/api/content/{type}/{id}` | Soft delete |
| Custom tables | Plugin storage collections | `ctx.storage.collectionName.put/get/query` |
### Site Configuration
| WordPress | EmDash | Notes |
| ------------------------ | --------------------------- | ---------------------------------------- |
| `get_bloginfo('name')` | `getSiteSetting('title')` | From `options` table with `site:` prefix |
| `get_option('blogdesc')` | `getSiteSetting('tagline')` | Site settings API |
| Theme Customizer | Site Settings admin page | `/_emdash/admin/settings` |
| `site_icon` | `getSiteSetting('favicon')` | Media reference |
| `custom_logo` | `getSiteSetting('logo')` | Media reference |
### Navigation Menus
| WordPress | EmDash | Notes |
| ---------------------- | --------------------------------------- | ----------------------------------- |
| `register_nav_menu()` | Create menu via admin or seed | `_emdash_menus` table |
| `wp_nav_menu()` | `getMenu(name)` | Returns `{ items: MenuItem[] }` |
| `wp_nav_menu_item` | `_emdash_menu_items` table | Type: custom, page, post, taxonomy |
| `_menu_item_object_id` | `reference_id` + `reference_collection` | Links to content entries |
| Menu locations | Query by name in templates | No locations concept — direct query |
### Taxonomies
| WordPress | EmDash | Notes |
| --------------------- | --------------------------------------- | ------------------------------ |
| `register_taxonomy()` | `_emdash_taxonomy_defs` table | Define via admin, seed, or API |
| `get_terms()` | `getTaxonomyTerms(name)` | Returns tree for hierarchical |
| `get_the_terms()` | `getEntryTerms(collection, id, name)` | Terms for specific entry |
| `wp_set_post_terms()` | `TaxonomyRepository.setTermsForEntry()` | Replace terms for entry |
| Hierarchical taxonomy | `hierarchical: true` in definition | Categories-style |
| Flat taxonomy | `hierarchical: false` | Tags-style |
### Widgets & Sidebars
| WordPress | EmDash | Notes |
| -------------------- | -------------------------------------- | ------------------------------- |
| `register_sidebar()` | `_emdash_widget_areas` table | Create via admin or seed |
| `dynamic_sidebar()` | `getWidgetArea(name)` | Returns `{ widgets: Widget[] }` |
| `WP_Widget` class | Widget types: content, menu, component | Simplified — 3 types only |
| Text widget | `type: 'content'` + Portable Text | Rich text widget |
| Nav Menu widget | `type: 'menu'` + `menuName` | References a menu |
| Custom widgets | `type: 'component'` + `componentId` | Plugin-registered components |
### Admin UI
| WordPress | EmDash | Notes |
| ------------------------ | --------------------------------- | ---------------------------------------- |
| `add_menu_page()` | `admin.pages` in `definePlugin()` | Plugin config |
| `add_submenu_page()` | Nested admin pages | Parent determines hierarchy |
| `add_settings_section()` | `admin.settingsSchema` | Auto-generated settings page |
| `add_meta_box()` | Field groups in collection schema | UI config in schema |
| `wp_enqueue_script()` | ESM imports in admin components | React (trusted) or Block Kit (sandboxed) |
| Admin notices | Toast notifications | Via admin UI framework |
### Hooks
| WordPress | EmDash | Notes |
| ---------------------------------- | --------------------------------------- | ----------------------------------------------------- |
| `add_action('init')` | `plugin:install` hook | Runs once on first install |
| `add_action('save_post')` | `content:afterSave` hook | Filter by `event.collection` |
| `add_action('before_delete_post')` | `content:beforeDelete` hook | Return false to prevent |
| `add_action('wp_head')` | `page:metadata` / `page:fragments` hook | Metadata is sandbox-safe; scripts need trusted plugin |
| `add_action('rest_api_init')` | `definePlugin({ routes })` | Trusted only |
| `add_filter('the_content')` | Portable Text components | Custom block renderers |
| `add_filter('the_title')` | Template logic | Handle in Astro component |
### Frontend Output
| WordPress | EmDash | Notes |
| ----------------------- | ---------------------------- | ---------------------------------------------------- |
| `add_shortcode()` | Portable Text custom block | Content → block. Template → component. Trusted only. |
| `register_block_type()` | PT block + `componentsEntry` | Block data → Astro component props. Trusted only. |
| Template tags | Astro expressions | `get_the_title()` → `{post.data.title}` |
| Widgets | Widget area + components | Query with `getWidgetArea()` |
### Plugin Storage
| WordPress | EmDash | Notes |
| ------------------------ | ------------------------ | ---------------------------------- |
| `get_option('plugin_*')` | `ctx.kv.get(key)` | Namespaced to plugin automatically |
| `update_option()` | `ctx.kv.set(key, value)` | Scoped KV storage |
| `delete_option()` | `ctx.kv.delete(key)` | Delete single key |
| Custom tables | `ctx.storage.collection` | Document collections with indexes |
| Transients | Plugin KV | No TTL yet |
## Porting-Specific Patterns
These patterns cover WordPress-specific concepts that don't have a direct 1:1 mapping. For general plugin patterns (defining hooks, storage, routes, admin UI), see the **creating-plugins** skill.
### Shortcodes → Portable Text Blocks
WordPress shortcodes (`[youtube id="xxx"]`) become Portable Text custom block types. The block data replaces shortcode attributes, and an Astro component replaces the shortcode render function. This is a trusted-only feature.
```typescript
// WordPress
add_shortcode('youtube', function($atts) {
return '<iframe src="https://youtube.com/embed/' . $atts['id'] . '"></iframe>';
});
// EmDash — block type declaration in definePlugin()
admin: {
portableTextBlocks: [{
type: "youtube",
label: "YouTube Video",
icon: "video",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
],
}],
}
// EmDash — Astro component for rendering
// src/astro/YouTube.astro
const { id, title } = Astro.props.node;
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
// <iframe src={`https://youtube-nocookie.com/embed/${videoId}`} ... />
```
### Options API → Plugin KV
WordPress's `get_option`/`update_option` maps to the plugin KV store. The key difference: WordPress options are global, EmDash KV is automatically scoped to the plugin.
```typescript
// WordPress
$count = get_option("myplugin_post_count", 0);
update_option("myplugin_post_count", $count + 1);
delete_option("myplugin_temp_data");
// EmDash — no prefix needed, automatically scoped
const count = (await ctx.kv.get<number>("post-count")) ?? 0;
await ctx.kv.set("post-count", count + 1);
await ctx.kv.delete("temp-data");
```
### Custom Database Tables → Storage Collections
WordPress plugins that create custom tables with `$wpdb->query("CREATE TABLE ...")` should use EmDash's storage collections instead. No migrations needed — declare the schema in `definePlugin()` and it's automatically provisioned.
```typescript
// WordPress
$wpdb->insert($table, ['form_id' => $id, 'data' => json_encode($data), 'created_at' => current_time('mysql')]);
$results = $wpdb->get_results("SELECT * FROM $table WHERE form_id = '$id' ORDER BY created_at DESC LIMIT 50");
// EmDash — declared in definePlugin()
storage: {
submissions: {
indexes: ["formId", "createdAt", ["formId", "createdAt"]],
},
},
// In a hook or route handler
await ctx.storage.submissions!.put(entryId, { formId, data, createdAt: new Date().toISOString() });
const result = await ctx.storage.submissions!.query({
where: { formId },
orderBy: { createdAt: "desc" },
limit: 50,
});
```
### Seeding Data (replaces starter content, theme setup)
WordPress plugins that call `wp_insert_term()`, `register_nav_menu()`, or insert default content on activation should use a seed file:
```json
{
"version": "1",
"settings": { "title": "My Site", "tagline": "Welcome" },
"taxonomies": [
{
"name": "category",
"label": "Categories",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" }
]
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "page", "ref": "about", "collection": "pages" }
]
}
],
"redirects": [
{ "source": "/?p=123", "destination": "/about" },
{ "source": "/old-contact", "destination": "/contact", "type": 301 }
]
}
```
Save to `.emdash/seed.json` (or wire up via `package.json#emdash.seed`); the runtime applies it on the next first-boot when the database is empty.
Use `redirects` for legacy WordPress URLs that still receive traffic after migration.
### Querying Content (replaces WP_Query)
```typescript
// WordPress
$query = new WP_Query(['post_type' => 'post', 'category_name' => 'tech', 'posts_per_page' => 10]);
// EmDash — in Astro component frontmatter
import { getEmDashCollection, getEntryTerms } from "emdash";
const { entries } = await getEmDashCollection("posts", {
where: { category: "technology" },
limit: 10,
});
```
### Menus (replaces wp_nav_menu)
```typescript
// WordPress
wp_nav_menu(['theme_location' => 'primary']);
// EmDash — in Astro component
import { getMenu } from "emdash";
const nav = await getMenu("primary");
// nav.items[].label, nav.items[].url, nav.items[].children
```
### Widget Areas (replaces dynamic_sidebar)
```typescript
// WordPress
dynamic_sidebar("sidebar-1");
// EmDash — in Astro component
import { getWidgetArea } from "emdash";
const sidebar = await getWidgetArea("sidebar");
// sidebar.widgets[].type: "content" | "menu" | "component"
```
## Red Flags (Need Human Decision)
Flag these for review — they may need architectural decisions:
1. **Deep WP integration** — Hooks into WP core features not in EmDash
2. **Theme dependencies** — Assumes specific theme structure
3. **Multisite features** — Not supported
4. **Complex WP_Query** — Meta queries may need custom implementation
5. **Direct SQL** — Schema differs, use Kysely or plugin storage
6. **Session/transient abuse** — Needs proper caching layer
7. **User capability checks** — Review role mapping (future)
8. **ob_start() buffering** — PHP pattern, rethink for streaming
9. **Cron jobs** — `wp_schedule_event()` has no direct equivalent; needs platform cron
## Output Format
When porting a plugin, provide:
1. **Analysis** — What the WP plugin does (concepts, not code)
2. **Concept mapping** — Which WP concepts map to which EmDash features
3. **Plugin code** — `src/descriptor.ts` and `src/index.ts` (use **creating-plugins** skill for structure)
4. **Seed data** — If plugin needs default taxonomies/menus/widgets
5. **Astro components** — For frontend output
6. **Flags** — Anything needing human decision
More from emdash-cms/emdash
- agent-browserBrowser automation for testing and verification. Use when you need to interact with web UIs, verify visual changes, fill forms, or capture screenshots.
- building-emdash-siteBuild and customize EmDash CMS sites on Astro. Use when creating pages, defining collections, writing seed files, querying content, rendering Portable Text, setting up menus/taxonomies/widgets, configuring deployment, or any task involving an EmDash-powered Astro site. Assumes basic Astro knowledge but provides all EmDash-specific patterns.
- creating-pluginsCreate EmDash CMS plugins with hooks, storage, settings, admin UI, API routes, and Portable Text block types. Use this skill when asked to build, scaffold, or implement an EmDash plugin, or when creating plugin features like custom block types, admin pages, or content hooks.
- diagnoseTrace from a reproduced symptom to the source code that causes it. Identify the specific file and approximate line, then rate confidence honestly.
- emdash-cliUse the EmDash CLI to manage content, schema, media, and more. Use this skill when you need to interact with a running EmDash instance from the command line — creating content, managing collections, uploading media, generating types, or scripting CMS operations.
- repro-adminReproduce an EmDash admin UI bug. Boots a demo with bgproc, drives the admin with agent-browser using the dev-bypass session, and captures the reproduction as screenshots plus a written transcript.
- repro-apiReproduce an EmDash bug that lives below the browser layer -- REST handlers, CLI, MCP, migrations, schema registry, or build tooling. No agent-browser. Prefer a failing vitest test in the affected package.
- repro-publicReproduce a bug in the public-facing rendered site (not the admin). Boots a demo with bgproc, drives the public routes with agent-browser, and captures the reproduction as screenshots plus a written transcript.
- verifyDecide whether the diagnosed behaviour is actually a bug or whether the code is doing what it was designed to do. Gate the fix stage.
- wordpress-theme-to-emdashPort WordPress themes to EmDash CMS. Use when asked to convert, migrate, or port a WordPress theme to EmDash, or when creating an EmDash site that should match an existing WordPress site's design. Handles design extraction, template conversion, and EmDash-specific features like menus, taxonomies, and widgets.