--- name: emdash-cms description: AI coding agent skill for building with EmDash, the full-stack TypeScript CMS built on Astro and Cloudflare triggers: - set up EmDash CMS - add EmDash to my Astro site - create a content type in EmDash - build an EmDash plugin - query EmDash content collections - migrate from WordPress to EmDash - configure EmDash with Cloudflare D1 - add authentication to my EmDash site --- # EmDash CMS > Skill by [ara.so](https://ara.so) — Daily 2026 Skills collection. EmDash is a full-stack TypeScript CMS built on Astro and Cloudflare. It is the spiritual successor to WordPress: extensible, developer-friendly, and powered by a plugin system that runs plugins in sandboxed Worker isolates rather than with full filesystem/database access. EmDash stores rich text as Portable Text (structured JSON) rather than HTML, supports passkey-first auth, and runs on Cloudflare (D1 + R2 + Workers) or any Node.js server with SQLite. --- ## Installation ### Scaffold a new project ```bash npm create emdash@latest ``` Follow the prompts to choose a template (blog, marketing, portfolio, starter, blank) and a platform (Cloudflare or Node.js/SQLite). ### Deploy to Cloudflare directly Use the one-click deploy button from the README, or: ```bash npm create emdash@latest -- --template blog-cloudflare cd my-site npm run deploy ``` ### Add EmDash to an existing Astro project ```bash npm install emdash ``` ```typescript // astro.config.mjs import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { d1 } from "emdash/db"; export default defineConfig({ integrations: [ emdash({ database: d1(), // Cloudflare D1 }), ], }); ``` For Node.js + SQLite (no Cloudflare account needed): ```typescript // astro.config.mjs import { defineConfig } from "astro/config"; import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; export default defineConfig({ integrations: [ emdash({ database: sqlite({ path: "./content.db" }), }), ], }); ``` --- ## Key CLI Commands ```bash # Scaffold a new EmDash project npm create emdash@latest # Generate TypeScript types from your live schema npx emdash types # Seed the demo site with sample content npx emdash seed # Run database migrations npx emdash migrate # Start the dev server (standard Astro command) npx astro dev # Build for production npx astro build # Open admin panel (after dev server starts) open http://localhost:4321/_emdash/admin ``` ### Monorepo / contributor commands ```bash pnpm install pnpm build pnpm test # run all tests pnpm typecheck # TypeScript check pnpm lint:quick # fast lint (< 1s) pnpm format # format with oxfmt # Run the demo (Node.js + SQLite, no Cloudflare needed) pnpm --filter emdash-demo seed pnpm --filter emdash-demo dev ``` --- ## Configuration ### Cloudflare (D1 + R2 + KV + Worker Loaders) ```jsonc // wrangler.jsonc { "name": "my-emdash-site", "compatibility_date": "2025-01-01", "d1_databases": [ { "binding": "DB", "database_name": "emdash-content", "database_id": "$DATABASE_ID" } ], "r2_buckets": [ { "binding": "MEDIA", "bucket_name": "emdash-media" } ], "kv_namespaces": [ { "binding": "SESSIONS", "id": "$KV_NAMESPACE_ID" } ], // Remove this block to disable sandboxed plugins (free accounts) "worker_loaders": [ { "binding": "PLUGIN_LOADER" } ] } ``` ```typescript // astro.config.mjs import emdash from "emdash/astro"; import { d1 } from "emdash/db"; import { r2 } from "emdash/storage"; import { kv } from "emdash/sessions"; export default defineConfig({ integrations: [ emdash({ database: d1({ binding: "DB" }), storage: r2({ binding: "MEDIA" }), sessions: kv({ binding: "SESSIONS" }), }), ], }); ``` ### Node.js + SQLite ```typescript // astro.config.mjs import emdash from "emdash/astro"; import { sqlite } from "emdash/db"; import { localFiles } from "emdash/storage"; export default defineConfig({ integrations: [ emdash({ database: sqlite({ path: "./content.db" }), storage: localFiles({ dir: "./public/uploads" }), }), ], }); ``` ### PostgreSQL / Turso ```typescript import { postgres } from "emdash/db"; import { turso } from "emdash/db"; // PostgreSQL database: postgres({ url: process.env.DATABASE_URL }) // Turso/libSQL database: turso({ url: process.env.TURSO_DATABASE_URL, authToken: process.env.TURSO_AUTH_TOKEN, }) ``` --- ## Querying Content Content types are defined in the admin UI (no code required). After creating a collection, generate types: ```bash npx emdash types ``` This writes type definitions to `src/emdash.d.ts`. ### Fetch a collection in an Astro page ```astro --- // src/pages/blog/index.astro import { getEmDashCollection } from "emdash"; const { entries: posts } = await getEmDashCollection("posts", { filter: { status: "published" }, sort: { field: "publishedAt", direction: "desc" }, limit: 10, }); --- ``` ### Fetch a single entry ```astro --- // src/pages/blog/[slug].astro import { getEmDashEntry, renderPortableText } from "emdash"; const { slug } = Astro.params; const post = await getEmDashEntry("posts", { slug }); if (!post) return Astro.redirect("/404"); const { Content } = await renderPortableText(post.data.body); ---

{post.data.title}

``` ### Pagination ```astro --- import { getEmDashCollection } from "emdash"; const page = Number(Astro.params.page ?? 1); const { entries: posts, total } = await getEmDashCollection("posts", { filter: { status: "published" }, sort: { field: "publishedAt", direction: "desc" }, limit: 10, offset: (page - 1) * 10, }); --- ``` ### Filtering by taxonomy ```astro --- import { getEmDashCollection } from "emdash"; const { entries: posts } = await getEmDashCollection("posts", { filter: { status: "published", tags: { contains: "typescript" }, }, }); --- ``` --- ## Portable Text Rendering EmDash stores rich text as [Portable Text](https://www.portabletext.org/) (structured JSON), not HTML. ```astro --- import { renderPortableText } from "emdash"; const post = await getEmDashEntry("posts", { slug: Astro.params.slug }); const { Content } = await renderPortableText(post.data.body); --- ``` ### Custom block renderers ```typescript // src/portable-text.ts import { definePortableTextComponents } from "emdash/blocks"; export const components = definePortableTextComponents({ types: { callout: ({ value }) => `
${value.text}
`, image: ({ value }) => `
${value.alt ?? ${value.caption ? `
${value.caption}
` : ""}
`, }, marks: { highlight: ({ children }) => `${children}`, }, }); ``` ```astro --- import { renderPortableText } from "emdash"; import { components } from "../portable-text"; const { Content } = await renderPortableText(post.data.body, { components }); --- ``` --- ## Plugin Development Plugins are the primary extension mechanism. On Cloudflare, they run in sandboxed Worker isolates with a declared capability manifest. On Node.js, they run in-process (safe mode). ### Minimal plugin ```typescript // plugins/my-plugin/index.ts import { definePlugin } from "emdash/plugin"; export default () => definePlugin({ id: "my-plugin", name: "My Plugin", version: "1.0.0", capabilities: [], hooks: {}, }); ``` ### Plugin with content hooks and email ```typescript import { definePlugin } from "emdash/plugin"; export default () => definePlugin({ id: "notify-on-publish", name: "Notify on Publish", version: "1.0.0", capabilities: ["read:content", "email:send"], hooks: { "content:afterSave": async (event, ctx) => { if (event.content.status !== "published") return; await ctx.email.send({ to: "editors@example.com", subject: `New post published: ${event.content.title}`, text: `"${event.content.title}" is now live.`, }); }, }, }); ``` ### Plugin with KV storage and admin settings ```typescript import { definePlugin } from "emdash/plugin"; export default () => definePlugin({ id: "view-counter", name: "View Counter", version: "1.0.0", capabilities: ["read:content", "kv:read", "kv:write"], settings: { schema: { resetDaily: { type: "boolean", default: false, label: "Reset counts daily" }, }, }, hooks: { "content:beforeRender": async (event, ctx) => { const key = `views:${event.content.id}`; const current = Number(await ctx.kv.get(key) ?? 0); await ctx.kv.set(key, String(current + 1)); event.content.meta.views = current + 1; }, }, adminPages: [ { path: "/analytics", title: "View Analytics", component: "ViewAnalytics", }, ], }); ``` ### Plugin with custom block type ```typescript import { definePlugin } from "emdash/plugin"; import { defineBlock } from "emdash/blocks"; export default () => definePlugin({ id: "callout-block", name: "Callout Block", version: "1.0.0", capabilities: [], blocks: [ defineBlock({ name: "callout", title: "Callout", fields: [ { name: "type", type: "select", options: ["info", "warning", "danger"], default: "info" }, { name: "text", type: "text", label: "Message" }, ], }), ], hooks: {}, }); ``` ### Plugin with custom API route ```typescript import { definePlugin } from "emdash/plugin"; export default () => definePlugin({ id: "newsletter", name: "Newsletter", version: "1.0.0", capabilities: ["kv:write"], apiRoutes: [ { method: "POST", path: "/subscribe", handler: async (request, ctx) => { const { email } = await request.json(); await ctx.kv.set(`subscriber:${email}`, "true"); return new Response(JSON.stringify({ ok: true }), { headers: { "Content-Type": "application/json" }, }); }, }, ], hooks: {}, }); ``` ### Available capabilities | Capability | What it allows | |---|---| | `read:content` | Read published content | | `write:content` | Create and update content | | `read:users` | Read user profiles | | `email:send` | Send email via configured provider | | `kv:read` | Read from plugin's KV namespace | | `kv:write` | Write to plugin's KV namespace | | `http:fetch` | Make outbound HTTP requests | | `storage:read` | Read from media storage | | `storage:write` | Write to media storage | ### Available hooks ```typescript // Content lifecycle "content:beforeSave" "content:afterSave" "content:beforeDelete" "content:afterDelete" "content:beforeRender" // Auth lifecycle "auth:afterLogin" "auth:afterLogout" "auth:afterRegister" // Media lifecycle "media:beforeUpload" "media:afterUpload" "media:beforeDelete" // Admin lifecycle "admin:init" ``` --- ## Authentication EmDash uses passkey-first (WebAuthn) authentication with OAuth and magic link fallbacks. ### Configure OAuth providers ```typescript // astro.config.mjs import emdash from "emdash/astro"; import { github, google } from "emdash/auth"; export default defineConfig({ integrations: [ emdash({ database: d1(), auth: { providers: [ github({ clientId: process.env.GITHUB_CLIENT_ID, clientSecret: process.env.GITHUB_CLIENT_SECRET, }), google({ clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }), ], }, }), ], }); ``` ### Configure magic link email ```typescript auth: { providers: [ magicLink({ from: "noreply@yourdomain.com", // uses the configured email adapter }), ], } ``` ### Protect a page ```astro --- // src/pages/dashboard.astro import { requireAuth } from "emdash/auth"; const user = await requireAuth(Astro); // Redirects to /login if not authenticated ---

Welcome, {user.name}!

``` ### Get the current user (optional) ```astro --- import { getUser } from "emdash/auth"; const user = await getUser(Astro); // null if not logged in --- {user ?

Hello {user.name}

: Sign in} ``` ### Roles ```typescript import { requireRole } from "emdash/auth"; // In an API route or page const user = await requireRole(Astro, "editor"); // Roles: "administrator" | "editor" | "author" | "contributor" ``` --- ## WordPress Migration ### Import from a WXR export file ```bash npx emdash import:wordpress ./export.xml ``` ### Import from the WordPress REST API ```bash npx emdash import:wordpress --url https://yoursite.wordpress.com --api-key $WP_API_KEY ``` ### Import from WordPress.com ```bash npx emdash import:wordpress --wpcom --site yoursite.wordpress.com ``` The importer migrates posts, pages, media attachments, categories, tags, authors, and comments. Gutenberg blocks are converted to Portable Text via the `gutenberg-to-portable-text` package. --- ## Content Schema (Admin UI) Content types are created in the admin panel at `/_emdash/admin` — not in code. After creating or modifying a collection, regenerate TypeScript types: ```bash npx emdash types # Writes to src/emdash.d.ts ``` ### Field types available in the schema builder - `text` — short string - `richText` — Portable Text (TipTap editor) - `number` — integer or float - `boolean` — true/false toggle - `date` / `datetime` — date pickers - `select` — single-choice dropdown - `multiSelect` — multi-choice - `image` — media library picker - `file` — file attachment - `relation` — link to another collection entry - `slug` — URL-safe string, auto-generated from a source field - `json` — raw JSON --- ## MCP Server (AI Tool Integration) EmDash includes a built-in MCP server so AI tools like Claude and ChatGPT can manage site content directly. ```typescript // astro.config.mjs import emdash from "emdash/astro"; export default defineConfig({ integrations: [ emdash({ database: d1(), mcp: { enabled: true, // Restrict to specific roles allowedRoles: ["administrator", "editor"], }, }), ], }); ``` The MCP server is available at `/_emdash/mcp`. Connect Claude Desktop by adding to `claude_desktop_config.json`: ```json { "mcpServers": { "emdash": { "url": "https://yoursite.com/_emdash/mcp", "headers": { "Authorization": "Bearer $EMDASH_MCP_TOKEN" } } } } ``` --- ## Repository Structure ``` packages/ core/ Astro integration, APIs, admin UI, CLI auth/ Authentication library blocks/ Portable Text block definitions cloudflare/ Cloudflare adapter (D1, R2, Worker Loader) plugins/ First-party plugins (forms, embeds, SEO, audit-log, etc.) create-emdash/ npm create emdash scaffolding gutenberg-to-portable-text/ WordPress block converter templates/ blog, marketing, portfolio, starter, blank demos/ Development example sites docs/ Starlight documentation site ``` --- ## First-Party Plugins Install from the `emdash/plugins` package: ```typescript import forms from "emdash/plugins/forms"; import seo from "emdash/plugins/seo"; import embeds from "emdash/plugins/embeds"; import auditLog from "emdash/plugins/audit-log"; export default defineConfig({ integrations: [ emdash({ database: d1(), plugins: [forms(), seo(), embeds(), auditLog()], }), ], }); ``` --- ## Troubleshooting ### "Dynamic Workers are not available on free accounts" Sandboxed plugins require a paid Cloudflare account ($5/mo+). To disable sandboxed plugins and run them in-process instead, remove the `worker_loaders` block from `wrangler.jsonc`: ```jsonc // wrangler.jsonc — remove this block on free accounts // "worker_loaders": [{ "binding": "PLUGIN_LOADER" }] ``` ### Admin panel returns 404 Ensure the Astro integration is registered in `astro.config.mjs` and the dev server has been restarted after installing EmDash. ### TypeScript errors after schema changes Regenerate types after modifying collections in the admin UI: ```bash npx emdash types ``` ### D1 binding errors locally Use `wrangler dev` instead of `astro dev` when developing with D1 bindings, or switch to SQLite for local development: ```typescript database: process.env.CF_PAGES ? d1() : sqlite({ path: "./content.db" }) ``` ### Migrations not applied ```bash npx emdash migrate # For Cloudflare D1 remote: npx wrangler d1 migrations apply emdash-content --remote ``` ### Plugin hook not firing Verify the plugin is listed in the `plugins` array in `astro.config.mjs` and that the capability required by the hook is declared in the plugin's `capabilities` array. Missing capabilities cause the sandbox to silently block the call.