Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/wordpress-plugin-to-emdash/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | — | — |
| case-02 | ✗→✓ | ▲ Improved | — | — |
| case-08 | ✗→✓ | ▲ Improved | — | — |
| case-04 | ✗→✓ | ▲ Improved | — | — |
| case-06 | ✗→✓ | ▲ Improved | — | — |
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.
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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() |
| 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 |
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.
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}`} ... />
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");
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, });
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.
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, });
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
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"
Flag these for review — they may need architectural decisions:
wp_schedule_event() has no direct equivalent; needs platform cronWhen porting a plugin, provide:
src/descriptor.ts and src/index.ts (use creating-plugins skill for structure)| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-03 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-02 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-08 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-04 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-06 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-01 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-07 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-21 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-09 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-16 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-12 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-19 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-13 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-20 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-11 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-18 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-15 | pass→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-14 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-22 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-10 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-17 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
case-23 | fail→fail | — | — | — | — | — | — | — | — | — | — | — | — |
case-05 | fail→pass | — | — | — | — | — | — | — | — | — | — | — | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +78 percentage points is the difference between those two pass rates over the 23 comparable cases.
The per-case answers from this run were removed by the retention sweep, so the case table below shows the verdicts without the text either arm produced. The counts above were recorded at the time and are unaffected. Answers are now kept for 180 days.
Other measured skills in the registry, with their headline benchmark lift.