From 879cf2f8f06ec63eaa6bfda734b159d67f64fcc7 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
Date: Mon, 11 May 2026 09:09:19 +0000
Subject: [PATCH] chore: sync templates from emdash v0.11.0
---
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
.../.agents/skills/emdash-cli/SKILL.md | 21 +++--
blog-cloudflare/.cursor/mcp.json | 8 ++
blog-cloudflare/.mcp.json | 8 ++
blog-cloudflare/.vscode/mcp.json | 8 ++
blog-cloudflare/AGENTS.md | 7 +-
blog-cloudflare/package.json | 14 ++--
blog-cloudflare/src/layouts/Base.astro | 50 +++++++-----
.../src/pages/category/[slug].astro | 2 +-
blog-cloudflare/src/pages/index.astro | 2 +-
blog-cloudflare/src/pages/pages/[slug].astro | 2 +-
blog-cloudflare/src/pages/posts/[slug].astro | 2 +-
blog-cloudflare/src/pages/posts/index.astro | 2 +-
blog-cloudflare/src/pages/tag/[slug].astro | 2 +-
blog-cloudflare/wrangler.jsonc | 2 -
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
blog/.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
blog/.agents/skills/emdash-cli/SKILL.md | 21 +++--
blog/.cursor/mcp.json | 8 ++
blog/.mcp.json | 8 ++
blog/.vscode/mcp.json | 8 ++
blog/AGENTS.md | 7 +-
blog/package.json | 6 +-
blog/src/layouts/Base.astro | 50 +++++++-----
blog/src/pages/category/[slug].astro | 2 +-
blog/src/pages/index.astro | 2 +-
blog/src/pages/pages/[slug].astro | 2 +-
blog/src/pages/posts/[slug].astro | 2 +-
blog/src/pages/posts/index.astro | 2 +-
blog/src/pages/tag/[slug].astro | 2 +-
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
.../.agents/skills/emdash-cli/SKILL.md | 21 +++--
marketing-cloudflare/.cursor/mcp.json | 8 ++
marketing-cloudflare/.gitignore | 3 +-
marketing-cloudflare/.mcp.json | 8 ++
marketing-cloudflare/.vscode/mcp.json | 8 ++
marketing-cloudflare/AGENTS.md | 7 +-
marketing-cloudflare/package.json | 10 +--
marketing-cloudflare/seed/seed.json | 27 +++++++
marketing-cloudflare/src/layouts/Base.astro | 46 ++++++-----
marketing-cloudflare/src/pages/index.astro | 2 +-
marketing-cloudflare/src/pages/pricing.astro | 2 +-
marketing-cloudflare/wrangler.jsonc | 2 -
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
marketing/.agents/skills/emdash-cli/SKILL.md | 21 +++--
marketing/.cursor/mcp.json | 8 ++
marketing/.gitignore | 3 +-
marketing/.mcp.json | 8 ++
marketing/.vscode/mcp.json | 8 ++
marketing/AGENTS.md | 7 +-
marketing/package.json | 4 +-
marketing/seed/seed.json | 27 +++++++
marketing/src/layouts/Base.astro | 46 ++++++-----
marketing/src/pages/index.astro | 2 +-
marketing/src/pages/pricing.astro | 2 +-
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
.../.agents/skills/emdash-cli/SKILL.md | 21 +++--
portfolio-cloudflare/.cursor/mcp.json | 8 ++
portfolio-cloudflare/.mcp.json | 8 ++
portfolio-cloudflare/.vscode/mcp.json | 8 ++
portfolio-cloudflare/AGENTS.md | 7 +-
portfolio-cloudflare/package.json | 10 +--
portfolio-cloudflare/src/pages/about.astro | 2 +-
portfolio-cloudflare/src/pages/index.astro | 2 +-
.../src/pages/work/[slug].astro | 2 +-
.../src/pages/work/index.astro | 2 +-
portfolio-cloudflare/wrangler.jsonc | 2 -
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
portfolio/.agents/skills/emdash-cli/SKILL.md | 21 +++--
portfolio/.cursor/mcp.json | 8 ++
portfolio/.mcp.json | 8 ++
portfolio/.vscode/mcp.json | 8 ++
portfolio/AGENTS.md | 7 +-
portfolio/package.json | 4 +-
portfolio/src/pages/about.astro | 2 +-
portfolio/src/pages/index.astro | 2 +-
portfolio/src/pages/work/[slug].astro | 2 +-
portfolio/src/pages/work/index.astro | 2 +-
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
.../.agents/skills/emdash-cli/SKILL.md | 21 +++--
starter-cloudflare/.cursor/mcp.json | 8 ++
starter-cloudflare/.mcp.json | 8 ++
starter-cloudflare/.vscode/mcp.json | 8 ++
starter-cloudflare/AGENTS.md | 7 +-
starter-cloudflare/package.json | 10 +--
starter-cloudflare/src/pages/[slug].astro | 2 +-
starter-cloudflare/src/pages/index.astro | 2 +-
.../src/pages/posts/[slug].astro | 2 +-
.../src/pages/posts/index.astro | 2 +-
starter-cloudflare/wrangler.jsonc | 2 -
.../skills/building-emdash-site/SKILL.md | 6 +-
.../references/configuration.md | 27 ++++++-
.../references/schema-and-seed.md | 17 ++---
.../.agents/skills/creating-plugins/SKILL.md | 48 ++++++------
.../creating-plugins/references/api-routes.md | 4 +-
.../creating-plugins/references/hooks.md | 76 +++++++++++++------
starter/.agents/skills/emdash-cli/SKILL.md | 21 +++--
starter/.cursor/mcp.json | 8 ++
starter/.mcp.json | 8 ++
starter/.vscode/mcp.json | 8 ++
starter/AGENTS.md | 7 +-
starter/package.json | 3 +-
starter/src/pages/[slug].astro | 2 +-
starter/src/pages/index.astro | 2 +-
starter/src/pages/posts/[slug].astro | 2 +-
starter/src/pages/posts/index.astro | 2 +-
140 files changed, 1433 insertions(+), 792 deletions(-)
create mode 100644 blog-cloudflare/.cursor/mcp.json
create mode 100644 blog-cloudflare/.mcp.json
create mode 100644 blog-cloudflare/.vscode/mcp.json
create mode 100644 blog/.cursor/mcp.json
create mode 100644 blog/.mcp.json
create mode 100644 blog/.vscode/mcp.json
create mode 100644 marketing-cloudflare/.cursor/mcp.json
create mode 100644 marketing-cloudflare/.mcp.json
create mode 100644 marketing-cloudflare/.vscode/mcp.json
create mode 100644 marketing/.cursor/mcp.json
create mode 100644 marketing/.mcp.json
create mode 100644 marketing/.vscode/mcp.json
create mode 100644 portfolio-cloudflare/.cursor/mcp.json
create mode 100644 portfolio-cloudflare/.mcp.json
create mode 100644 portfolio-cloudflare/.vscode/mcp.json
create mode 100644 portfolio/.cursor/mcp.json
create mode 100644 portfolio/.mcp.json
create mode 100644 portfolio/.vscode/mcp.json
create mode 100644 starter-cloudflare/.cursor/mcp.json
create mode 100644 starter-cloudflare/.mcp.json
create mode 100644 starter-cloudflare/.vscode/mcp.json
create mode 100644 starter/.cursor/mcp.json
create mode 100644 starter/.mcp.json
create mode 100644 starter/.vscode/mcp.json
diff --git a/blog-cloudflare/.agents/skills/building-emdash-site/SKILL.md b/blog-cloudflare/.agents/skills/building-emdash-site/SKILL.md
index fb2d5f3..ad3facd 100644
--- a/blog-cloudflare/.agents/skills/building-emdash-site/SKILL.md
+++ b/blog-cloudflare/.agents/skills/building-emdash-site/SKILL.md
@@ -59,11 +59,7 @@ Read **[references/site-features.md](references/site-features.md)** for site set
### 5. Create the seed file
-Write `seed/seed.json` with collections, fields, taxonomies, menus, widgets, and sample content. Validate with:
-
-```bash
-npx emdash seed seed/seed.json --validate
-```
+Write `seed/seed.json` with collections, fields, taxonomies, menus, widgets, and sample content.
### 6. Run and verify
diff --git a/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md b/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md
index d24f221..6f4685c 100644
--- a/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md
+++ b/blog-cloudflare/.agents/skills/building-emdash-site/references/configuration.md
@@ -32,6 +32,32 @@ export default defineConfig({
});
```
+### Reverse proxy
+
+When behind a TLS-terminating reverse proxy, `Astro.url` returns the internal address (e.g. `http://localhost:4321`) instead of the public one (`https://mysite.example.com`). This breaks passkeys, CSRF, OAuth, redirects, and more.
+
+**Step 1:** Declare allowed public hosts via [`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) so Astro reconstructs the URL from `X-Forwarded-*` headers. In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`.
+
+**Step 2:** If the reconstructed URL still disagrees with the browser (common with TLS termination), set **`siteUrl`**:
+
+```javascript
+emdash({
+ siteUrl: "https://mysite.example.com",
+ // ...
+});
+```
+
+Or via environment variable (useful for container deployments):
+
+```bash
+EMDASH_SITE_URL=https://mysite.example.com
+# or: SITE_URL=https://mysite.example.com
+```
+
+`siteUrl` replaces `passkeyPublicOrigin` (which only fixed passkeys). It applies to passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data.
+
+With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`siteUrl`** matches the browser’s HTTPS origin -- without opening the Node port on the LAN.
+
### Cloudflare (D1 + R2)
```javascript
@@ -71,7 +97,6 @@ Requires a `wrangler.jsonc` with D1 and R2 bindings:
{
"binding": "DB",
"database_name": "my-site",
- "database_id": "", // from `wrangler d1 create my-site`
},
],
"r2_buckets": [
diff --git a/blog-cloudflare/.agents/skills/building-emdash-site/references/schema-and-seed.md b/blog-cloudflare/.agents/skills/building-emdash-site/references/schema-and-seed.md
index 378c657..f3fee9b 100644
--- a/blog-cloudflare/.agents/skills/building-emdash-site/references/schema-and-seed.md
+++ b/blog-cloudflare/.agents/skills/building-emdash-site/references/schema-and-seed.md
@@ -1,6 +1,6 @@
# Schema and Seed Files
-The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's applied on first run or via `npx emdash seed seed/seed.json`.
+The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's inlined into the build and applied automatically on the first request when the database is empty and the setup wizard hasn't been completed.
## Seed File Structure
@@ -440,25 +440,18 @@ Set `"status": "draft"` to create unpublished content:
}
```
-## Validation
+## Applying Seeds
-```bash
-npx emdash seed seed/seed.json --validate
-```
+The seed at `.emdash/seed.json`, `package.json#emdash.seed`, or `seed/seed.json` is inlined into the build and applied on the first request when the database is empty and the setup wizard hasn't been completed. Existing data is never overwritten.
-Catches:
+Validation runs at apply time. Common errors caught:
- Image fields with raw URLs (should use `$media`)
- Reference fields with raw IDs (should use `$ref:id`)
- PortableText not an array or missing `_type`
- Type mismatches (string vs number, etc.)
-## Applying Seeds
-
-```bash
-npx emdash seed seed/seed.json # Apply with content
-npx emdash seed seed/seed.json --no-content # Schema only (no sample content)
-```
+If the seed is invalid, the first request fails and the error is logged. Restart the dev server after fixing it.
## Exporting Seeds
diff --git a/blog-cloudflare/.agents/skills/creating-plugins/SKILL.md b/blog-cloudflare/.agents/skills/creating-plugins/SKILL.md
index a9680dc..35ddd9e 100644
--- a/blog-cloudflare/.agents/skills/creating-plugins/SKILL.md
+++ b/blog-cloudflare/.agents/skills/creating-plugins/SKILL.md
@@ -132,14 +132,14 @@ EmDash has two execution modes. Plugin code is identical in both — only the en
Trusted plugins are npm packages or local files added in `astro.config.mjs`. They run in-process with your Astro site.
-- **Capabilities are documentation only.** Declaring `["read:content"]` documents intent but isn't enforced — the plugin has full process access.
+- **Capabilities are documentation only.** Declaring `["content:read"]` documents intent but isn't enforced — the plugin has full process access.
- Only install from sources you trust. A malicious trusted plugin has the same access as your application code.
### Sandboxed Mode
Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). Each plugin gets its own isolate.
-- **Capabilities are enforced.** If a plugin declares `["read:content"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error.
+- **Capabilities are enforced.** If a plugin declares `["content:read"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error.
- **Network is blocked by default.** Direct `fetch()` calls fail. Plugins must use `ctx.http.fetch()`, which validates against `allowedHosts`.
- **Storage is scoped.** A plugin can only access its own KV and storage collections.
- **Admin UI uses Block Kit.** Sandboxed plugins describe their UI as JSON blocks -- no plugin JavaScript runs in the browser. See [Block Kit reference](./references/block-kit.md).
@@ -161,7 +161,7 @@ export default definePlugin({
hooks: {
"content:afterSave": {
handler: async (event: any, ctx: PluginContext) => {
- // Trusted: ctx.http present because descriptor declares network:fetch
+ // Trusted: ctx.http present because descriptor declares network:request
// Sandboxed: ctx.http present and enforced via RPC bridge
if (!ctx.http) return;
await ctx.http.fetch("https://api.analytics.example.com/track", {
@@ -180,25 +180,27 @@ Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path`
Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.
-| Capability | Grants | `ctx` property |
-| ----------------- | ---------------------------------------------------------------------- | -------------- |
-| `read:content` | `ctx.content.get()`, `ctx.content.list()` | `content` |
-| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
-| `read:media` | `ctx.media.get()`, `ctx.media.list()` | `media` |
-| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
-| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
-| `read:users` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
-| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
-| `email:provide` | Can register `email:deliver` exclusive hook (transport provider) | — |
-| `email:intercept` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| Capability | Grants | `ctx` property |
+| -------------------------------- | ---------------------------------------------------------------------- | -------------- |
+| `content:read` | `ctx.content.get()`, `ctx.content.list()` | `content` |
+| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
+| `media:read` | `ctx.media.get()`, `ctx.media.list()` | `media` |
+| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
+| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
+| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) | `http` |
+| `users:read` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
+| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
+| `hooks.email-transport:register` | Can register `email:deliver` exclusive hook (transport provider) | — |
+| `hooks.email-events:register` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| `hooks.page-fragments:register` | Can register `page:fragments` hook (inject scripts/styles into pages) | — |
Storage (`ctx.storage`) and KV (`ctx.kv`) are **always available** — no capability needed. They're automatically scoped to the plugin.
**Email capabilities are distinct:**
- `email:send` — for plugins that _consume_ email (call `ctx.email.send()`)
-- `email:provide` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
-- `email:intercept` — for plugins that _observe or transform_ email (middleware hooks)
+- `hooks.email-transport:register` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
+- `hooks.email-events:register` — for plugins that _observe or transform_ email (middleware hooks)
```typescript
// In the descriptor (index.ts)
@@ -209,7 +211,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com", "*.googleapis.com"], // Wildcards supported
};
}
@@ -296,7 +298,7 @@ export function submissionsPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/plugin-submissions/sandbox",
options: {},
- capabilities: ["read:content"],
+ capabilities: ["content:read"],
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
@@ -415,10 +417,10 @@ interface PluginContext {
storage: Record; // Declared collections
kv: KVAccess; // Key-value store
log: LogAccess; // Structured logger
- content?: ContentAccess; // If "read:content" capability
- media?: MediaAccess; // If "read:media" capability
- http?: HttpAccess; // If "network:fetch" capability
- users?: UserAccess; // If "read:users" capability
+ content?: ContentAccess; // If "content:read" capability
+ media?: MediaAccess; // If "media:read" capability
+ http?: HttpAccess; // If "network:request" capability
+ users?: UserAccess; // If "users:read" capability
cron?: CronAccess; // Always available — scoped to plugin
email?: EmailAccess; // If "email:send" capability AND a provider is configured
}
@@ -435,7 +437,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
storage: { events: { indexes: ["timestamp"] } },
};
diff --git a/blog-cloudflare/.agents/skills/creating-plugins/references/api-routes.md b/blog-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
index c836fd6..c4d2419 100644
--- a/blog-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
+++ b/blog-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
@@ -215,11 +215,11 @@ routes: {
### External API Proxy
-Requires `network:fetch` capability and `allowedHosts`:
+Requires `network:request` capability and `allowedHosts`:
```typescript
definePlugin({
- capabilities: ["network:fetch"],
+ capabilities: ["network:request"],
allowedHosts: ["api.weather.example.com"],
routes: {
diff --git a/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 31dec47..c8ba16e 100644
--- a/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/blog-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -163,6 +163,32 @@ Runs after successful delete.
Event: `{ id: string, collection: string }`
Returns: `void`
+### `content:afterPublish`
+
+Runs after content is published (promoted from draft to live). Side effects only.
+
+```typescript
+"content:afterPublish": async (event, ctx) => {
+ ctx.log.info(`Published ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
+### `content:afterUnpublish`
+
+Runs after content is unpublished (reverted to draft). Side effects only.
+
+```typescript
+"content:afterUnpublish": async (event, ctx) => {
+ ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
## Media Hooks
### `media:beforeUpload`
@@ -207,14 +233,14 @@ Email hooks require specific capabilities. Without the required capability, hook
### `email:beforeSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
definePlugin({
id: "email-footer",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:beforeSend": async (event, ctx) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
@@ -228,14 +254,14 @@ Returns: `EmailMessage | false`
### `email:deliver`
-**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.
+**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
definePlugin({
id: "emdash-resend",
- capabilities: ["email:provide", "network:fetch"],
+ capabilities: ["hooks.email-transport:register", "network:request"],
allowedHosts: ["api.resend.com"],
hooks: {
"email:deliver": {
@@ -258,14 +284,14 @@ Returns: `void`
### `email:afterSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
definePlugin({
id: "email-logger",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
@@ -392,21 +418,23 @@ Use `"continue"` for non-critical operations (analytics, notifications, external
## Quick Reference
-| Hook | Trigger | Capability Required | Return |
-| ---------------------- | -------------------- | ------------------- | ---------------------------- |
-| `plugin:install` | First install | — | `void` |
-| `plugin:activate` | Plugin enabled | — | `void` |
-| `plugin:deactivate` | Plugin disabled | — | `void` |
-| `plugin:uninstall` | Plugin removed | — | `void` |
-| `content:beforeSave` | Before save | — | Modified content or `void` |
-| `content:afterSave` | After save | — | `void` |
-| `content:beforeDelete` | Before delete | — | `false` to cancel |
-| `content:afterDelete` | After delete | — | `void` |
-| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
-| `media:afterUpload` | After upload | — | `void` |
-| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` |
-| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) |
-| `email:afterSend` | After email send | `email:intercept` | `void` |
-| `cron` | Scheduled task fires | — | `void` |
-| `page:metadata` | Page render | — | Metadata contributions |
-| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
+| Hook | Trigger | Capability Required | Return |
+| ------------------------ | -------------------- | -------------------------------- | ---------------------------- |
+| `plugin:install` | First install | — | `void` |
+| `plugin:activate` | Plugin enabled | — | `void` |
+| `plugin:deactivate` | Plugin disabled | — | `void` |
+| `plugin:uninstall` | Plugin removed | — | `void` |
+| `content:beforeSave` | Before save | `content:write` | Modified content or `void` |
+| `content:afterSave` | After save | `content:read` | `void` |
+| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel |
+| `content:afterDelete` | After delete | `content:read` | `void` |
+| `content:afterPublish` | After publish | `content:read` | `void` |
+| `content:afterUnpublish` | After unpublish | `content:read` | `void` |
+| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
+| `media:afterUpload` | After upload | — | `void` |
+| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` |
+| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) |
+| `email:afterSend` | After email send | `hooks.email-events:register` | `void` |
+| `cron` | Scheduled task fires | — | `void` |
+| `page:metadata` | Page render | — | Metadata contributions |
+| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
diff --git a/blog-cloudflare/.agents/skills/emdash-cli/SKILL.md b/blog-cloudflare/.agents/skills/emdash-cli/SKILL.md
index 8052de7..5386e09 100644
--- a/blog-cloudflare/.agents/skills/emdash-cli/SKILL.md
+++ b/blog-cloudflare/.agents/skills/emdash-cli/SKILL.md
@@ -70,22 +70,21 @@ npx emdash login --url https://example.com -H "X-API-Key: secret123"
### Database Setup
-```bash
-# Initialize database with migrations
-npx emdash init
+Migrations and seed application happen automatically inside the runtime — there's no separate init/seed step. Just start the dev server (or deploy) and the first request runs pending migrations and applies the bundled seed if the database is empty.
-# Start dev server (runs migrations, starts Astro)
+```bash
+# Start dev server (runs migrations, applies seed on empty DB, starts Astro)
npx emdash dev
# Start dev server and generate types from remote
npx emdash dev --types
-# Apply a seed file
-npx emdash seed .emdash/seed.json
-
-# Export database as seed
-npx emdash export-seed > seed.json
-npx emdash export-seed --with-content > seed.json
+# Export an existing database as a seed file
+# (the runtime auto-discovers .emdash/seed.json on first boot;
+# `mkdir -p` because the directory may not exist yet)
+mkdir -p .emdash
+npx emdash export-seed > .emdash/seed.json
+npx emdash export-seed --with-content > .emdash/seed.json
```
### Type Generation
@@ -177,7 +176,7 @@ npx emdash schema add-field posts featured --type boolean --required
npx emdash schema remove-field posts featured
```
-Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `image`, `reference`, `portableText`, `json`.
+Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `image`, `file`, `reference`, `portableText`, `json`, `slug`, `url`. See `FIELD_TYPE_TO_COLUMN` in `packages/core/src/schema/types.ts` for the authoritative list.
### Media
diff --git a/blog-cloudflare/.cursor/mcp.json b/blog-cloudflare/.cursor/mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/blog-cloudflare/.cursor/mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog-cloudflare/.mcp.json b/blog-cloudflare/.mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/blog-cloudflare/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog-cloudflare/.vscode/mcp.json b/blog-cloudflare/.vscode/mcp.json
new file mode 100644
index 0000000..1401a6f
--- /dev/null
+++ b/blog-cloudflare/.vscode/mcp.json
@@ -0,0 +1,8 @@
+{
+ "servers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog-cloudflare/AGENTS.md b/blog-cloudflare/AGENTS.md
index 9524aad..9af5138 100644
--- a/blog-cloudflare/AGENTS.md
+++ b/blog-cloudflare/AGENTS.md
@@ -5,7 +5,6 @@ This is an EmDash site -- a CMS built on Astro with a full admin UI.
```bash
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
npx emdash types # Regenerate TypeScript types from schema
-npx emdash seed seed/seed.json --validate # Validate seed file
```
The admin UI is at `http://localhost:4321/_emdash/admin`.
@@ -29,6 +28,12 @@ Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
+## Documentation
+
+The EmDash docs are available as an MCP server at `https://docs.emdashcms.com/mcp`. When you need to verify an API, hook, config option, field type, or pattern, call `search_docs` against the live documentation rather than relying on training-data recall. The docs reflect current behaviour; assumptions may not.
+
+This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json` so Claude Code, Cursor, and VS Code auto-discover the docs server. Other tools (OpenCode, Windsurf, etc.) need a manual one-time setup -- see [docs.emdashcms.com/docs-mcp](https://docs.emdashcms.com/docs-mcp).
+
## Rules
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
diff --git a/blog-cloudflare/package.json b/blog-cloudflare/package.json
index 818c39c..0df2464 100644
--- a/blog-cloudflare/package.json
+++ b/blog-cloudflare/package.json
@@ -11,25 +11,23 @@
"build": "astro build",
"preview": "astro preview",
"deploy": "astro build && wrangler deploy",
- "typecheck": "astro check",
- "bootstrap": "emdash init && emdash seed",
- "seed": "emdash seed"
+ "typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "^13.1.7",
"@astrojs/react": "^5.0.0",
- "@emdash-cms/cloudflare": "^0.9.0",
- "@emdash-cms/plugin-forms": "^0.2.0",
- "@emdash-cms/plugin-webhook-notifier": "^0.1.2",
+ "@emdash-cms/cloudflare": "^0.11.0",
+ "@emdash-cms/plugin-forms": "^0.2.2",
+ "@emdash-cms/plugin-webhook-notifier": "^0.1.3",
"astro": "^6.0.1",
- "emdash": "^0.9.0",
+ "emdash": "^0.11.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.7",
"@cloudflare/workers-types": "^4.20260305.1",
- "wrangler": "^4.80.0"
+ "wrangler": "^4.83.0"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/blog-cloudflare/src/layouts/Base.astro b/blog-cloudflare/src/layouts/Base.astro
index df078d8..67c46e3 100644
--- a/blog-cloudflare/src/layouts/Base.astro
+++ b/blog-cloudflare/src/layouts/Base.astro
@@ -127,14 +127,14 @@ const isLoggedIn = !!Astro.locals.user;
))
}
- {
- isLoggedIn && (
-
- Admin
-
- )
- }
+ {
+ isLoggedIn && (
+
+ Admin
+
+ )
+ }
@@ -620,6 +620,7 @@ const isLoggedIn = !!Astro.locals.user;
display: flex;
align-items: center;
gap: var(--spacing-6);
+ margin-inline-start: auto;
}
.nav-links {
@@ -928,37 +929,43 @@ const isLoggedIn = !!Astro.locals.user;
}
.nav {
- flex-direction: row;
- flex-wrap: wrap;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: auto;
- gap: var(--spacing-2);
+ column-gap: var(--spacing-3);
+ row-gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
.site-title {
+ grid-column: 1;
+ grid-row: 1;
font-size: var(--font-size-base);
+ min-width: 0;
}
.nav-right {
- display: contents;
+ grid-column: 1 / -1;
+ grid-row: 2;
+ display: grid;
+ row-gap: var(--spacing-2);
}
.site-search {
- order: 0;
- max-width: 140px;
+ grid-column: 1;
+ width: 100%;
+ max-width: none;
}
:global(.site-search-input) {
- width: 140px !important;
+ width: 100% !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}
.nav-links {
- order: 1;
- width: 100%;
+ grid-column: 1;
display: flex;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
@@ -967,11 +974,12 @@ const isLoggedIn = !!Astro.locals.user;
}
.nav-admin {
- order: 2;
- position: absolute;
- right: var(--spacing-4);
- top: var(--spacing-3);
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: end;
font-size: var(--font-size-xs);
+ margin-inline-start: 0;
+ white-space: nowrap;
}
.footer-grid {
diff --git a/blog-cloudflare/src/pages/category/[slug].astro b/blog-cloudflare/src/pages/category/[slug].astro
index 6920785..f0bbf70 100644
--- a/blog-cloudflare/src/pages/category/[slug].astro
+++ b/blog-cloudflare/src/pages/category/[slug].astro
@@ -21,7 +21,7 @@ const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
diff --git a/blog-cloudflare/src/pages/index.astro b/blog-cloudflare/src/pages/index.astro
index 790c0c0..da6b71c 100644
--- a/blog-cloudflare/src/pages/index.astro
+++ b/blog-cloudflare/src/pages/index.astro
@@ -23,7 +23,7 @@ const [{ entries: posts, cacheHint }, settings] = await Promise.all([
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
diff --git a/blog-cloudflare/src/pages/pages/[slug].astro b/blog-cloudflare/src/pages/pages/[slug].astro
index 2e81625..3c15fea 100644
--- a/blog-cloudflare/src/pages/pages/[slug].astro
+++ b/blog-cloudflare/src/pages/pages/[slug].astro
@@ -15,7 +15,7 @@ if (!page) {
return Astro.redirect("/404");
}
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
{
- // Trusted: ctx.http present because descriptor declares network:fetch
+ // Trusted: ctx.http present because descriptor declares network:request
// Sandboxed: ctx.http present and enforced via RPC bridge
if (!ctx.http) return;
await ctx.http.fetch("https://api.analytics.example.com/track", {
@@ -180,25 +180,27 @@ Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path`
Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.
-| Capability | Grants | `ctx` property |
-| ----------------- | ---------------------------------------------------------------------- | -------------- |
-| `read:content` | `ctx.content.get()`, `ctx.content.list()` | `content` |
-| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
-| `read:media` | `ctx.media.get()`, `ctx.media.list()` | `media` |
-| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
-| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
-| `read:users` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
-| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
-| `email:provide` | Can register `email:deliver` exclusive hook (transport provider) | — |
-| `email:intercept` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| Capability | Grants | `ctx` property |
+| -------------------------------- | ---------------------------------------------------------------------- | -------------- |
+| `content:read` | `ctx.content.get()`, `ctx.content.list()` | `content` |
+| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
+| `media:read` | `ctx.media.get()`, `ctx.media.list()` | `media` |
+| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
+| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
+| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) | `http` |
+| `users:read` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
+| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
+| `hooks.email-transport:register` | Can register `email:deliver` exclusive hook (transport provider) | — |
+| `hooks.email-events:register` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| `hooks.page-fragments:register` | Can register `page:fragments` hook (inject scripts/styles into pages) | — |
Storage (`ctx.storage`) and KV (`ctx.kv`) are **always available** — no capability needed. They're automatically scoped to the plugin.
**Email capabilities are distinct:**
- `email:send` — for plugins that _consume_ email (call `ctx.email.send()`)
-- `email:provide` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
-- `email:intercept` — for plugins that _observe or transform_ email (middleware hooks)
+- `hooks.email-transport:register` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
+- `hooks.email-events:register` — for plugins that _observe or transform_ email (middleware hooks)
```typescript
// In the descriptor (index.ts)
@@ -209,7 +211,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com", "*.googleapis.com"], // Wildcards supported
};
}
@@ -296,7 +298,7 @@ export function submissionsPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/plugin-submissions/sandbox",
options: {},
- capabilities: ["read:content"],
+ capabilities: ["content:read"],
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
@@ -415,10 +417,10 @@ interface PluginContext {
storage: Record; // Declared collections
kv: KVAccess; // Key-value store
log: LogAccess; // Structured logger
- content?: ContentAccess; // If "read:content" capability
- media?: MediaAccess; // If "read:media" capability
- http?: HttpAccess; // If "network:fetch" capability
- users?: UserAccess; // If "read:users" capability
+ content?: ContentAccess; // If "content:read" capability
+ media?: MediaAccess; // If "media:read" capability
+ http?: HttpAccess; // If "network:request" capability
+ users?: UserAccess; // If "users:read" capability
cron?: CronAccess; // Always available — scoped to plugin
email?: EmailAccess; // If "email:send" capability AND a provider is configured
}
@@ -435,7 +437,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
storage: { events: { indexes: ["timestamp"] } },
};
diff --git a/blog/.agents/skills/creating-plugins/references/api-routes.md b/blog/.agents/skills/creating-plugins/references/api-routes.md
index c836fd6..c4d2419 100644
--- a/blog/.agents/skills/creating-plugins/references/api-routes.md
+++ b/blog/.agents/skills/creating-plugins/references/api-routes.md
@@ -215,11 +215,11 @@ routes: {
### External API Proxy
-Requires `network:fetch` capability and `allowedHosts`:
+Requires `network:request` capability and `allowedHosts`:
```typescript
definePlugin({
- capabilities: ["network:fetch"],
+ capabilities: ["network:request"],
allowedHosts: ["api.weather.example.com"],
routes: {
diff --git a/blog/.agents/skills/creating-plugins/references/hooks.md b/blog/.agents/skills/creating-plugins/references/hooks.md
index 31dec47..c8ba16e 100644
--- a/blog/.agents/skills/creating-plugins/references/hooks.md
+++ b/blog/.agents/skills/creating-plugins/references/hooks.md
@@ -163,6 +163,32 @@ Runs after successful delete.
Event: `{ id: string, collection: string }`
Returns: `void`
+### `content:afterPublish`
+
+Runs after content is published (promoted from draft to live). Side effects only.
+
+```typescript
+"content:afterPublish": async (event, ctx) => {
+ ctx.log.info(`Published ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
+### `content:afterUnpublish`
+
+Runs after content is unpublished (reverted to draft). Side effects only.
+
+```typescript
+"content:afterUnpublish": async (event, ctx) => {
+ ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
## Media Hooks
### `media:beforeUpload`
@@ -207,14 +233,14 @@ Email hooks require specific capabilities. Without the required capability, hook
### `email:beforeSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
definePlugin({
id: "email-footer",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:beforeSend": async (event, ctx) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
@@ -228,14 +254,14 @@ Returns: `EmailMessage | false`
### `email:deliver`
-**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.
+**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
definePlugin({
id: "emdash-resend",
- capabilities: ["email:provide", "network:fetch"],
+ capabilities: ["hooks.email-transport:register", "network:request"],
allowedHosts: ["api.resend.com"],
hooks: {
"email:deliver": {
@@ -258,14 +284,14 @@ Returns: `void`
### `email:afterSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
definePlugin({
id: "email-logger",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
@@ -392,21 +418,23 @@ Use `"continue"` for non-critical operations (analytics, notifications, external
## Quick Reference
-| Hook | Trigger | Capability Required | Return |
-| ---------------------- | -------------------- | ------------------- | ---------------------------- |
-| `plugin:install` | First install | — | `void` |
-| `plugin:activate` | Plugin enabled | — | `void` |
-| `plugin:deactivate` | Plugin disabled | — | `void` |
-| `plugin:uninstall` | Plugin removed | — | `void` |
-| `content:beforeSave` | Before save | — | Modified content or `void` |
-| `content:afterSave` | After save | — | `void` |
-| `content:beforeDelete` | Before delete | — | `false` to cancel |
-| `content:afterDelete` | After delete | — | `void` |
-| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
-| `media:afterUpload` | After upload | — | `void` |
-| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` |
-| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) |
-| `email:afterSend` | After email send | `email:intercept` | `void` |
-| `cron` | Scheduled task fires | — | `void` |
-| `page:metadata` | Page render | — | Metadata contributions |
-| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
+| Hook | Trigger | Capability Required | Return |
+| ------------------------ | -------------------- | -------------------------------- | ---------------------------- |
+| `plugin:install` | First install | — | `void` |
+| `plugin:activate` | Plugin enabled | — | `void` |
+| `plugin:deactivate` | Plugin disabled | — | `void` |
+| `plugin:uninstall` | Plugin removed | — | `void` |
+| `content:beforeSave` | Before save | `content:write` | Modified content or `void` |
+| `content:afterSave` | After save | `content:read` | `void` |
+| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel |
+| `content:afterDelete` | After delete | `content:read` | `void` |
+| `content:afterPublish` | After publish | `content:read` | `void` |
+| `content:afterUnpublish` | After unpublish | `content:read` | `void` |
+| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
+| `media:afterUpload` | After upload | — | `void` |
+| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` |
+| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) |
+| `email:afterSend` | After email send | `hooks.email-events:register` | `void` |
+| `cron` | Scheduled task fires | — | `void` |
+| `page:metadata` | Page render | — | Metadata contributions |
+| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
diff --git a/blog/.agents/skills/emdash-cli/SKILL.md b/blog/.agents/skills/emdash-cli/SKILL.md
index 8052de7..5386e09 100644
--- a/blog/.agents/skills/emdash-cli/SKILL.md
+++ b/blog/.agents/skills/emdash-cli/SKILL.md
@@ -70,22 +70,21 @@ npx emdash login --url https://example.com -H "X-API-Key: secret123"
### Database Setup
-```bash
-# Initialize database with migrations
-npx emdash init
+Migrations and seed application happen automatically inside the runtime — there's no separate init/seed step. Just start the dev server (or deploy) and the first request runs pending migrations and applies the bundled seed if the database is empty.
-# Start dev server (runs migrations, starts Astro)
+```bash
+# Start dev server (runs migrations, applies seed on empty DB, starts Astro)
npx emdash dev
# Start dev server and generate types from remote
npx emdash dev --types
-# Apply a seed file
-npx emdash seed .emdash/seed.json
-
-# Export database as seed
-npx emdash export-seed > seed.json
-npx emdash export-seed --with-content > seed.json
+# Export an existing database as a seed file
+# (the runtime auto-discovers .emdash/seed.json on first boot;
+# `mkdir -p` because the directory may not exist yet)
+mkdir -p .emdash
+npx emdash export-seed > .emdash/seed.json
+npx emdash export-seed --with-content > .emdash/seed.json
```
### Type Generation
@@ -177,7 +176,7 @@ npx emdash schema add-field posts featured --type boolean --required
npx emdash schema remove-field posts featured
```
-Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `image`, `reference`, `portableText`, `json`.
+Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `image`, `file`, `reference`, `portableText`, `json`, `slug`, `url`. See `FIELD_TYPE_TO_COLUMN` in `packages/core/src/schema/types.ts` for the authoritative list.
### Media
diff --git a/blog/.cursor/mcp.json b/blog/.cursor/mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/blog/.cursor/mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog/.mcp.json b/blog/.mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/blog/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog/.vscode/mcp.json b/blog/.vscode/mcp.json
new file mode 100644
index 0000000..1401a6f
--- /dev/null
+++ b/blog/.vscode/mcp.json
@@ -0,0 +1,8 @@
+{
+ "servers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/blog/AGENTS.md b/blog/AGENTS.md
index 9524aad..9af5138 100644
--- a/blog/AGENTS.md
+++ b/blog/AGENTS.md
@@ -5,7 +5,6 @@ This is an EmDash site -- a CMS built on Astro with a full admin UI.
```bash
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
npx emdash types # Regenerate TypeScript types from schema
-npx emdash seed seed/seed.json --validate # Validate seed file
```
The admin UI is at `http://localhost:4321/_emdash/admin`.
@@ -29,6 +28,12 @@ Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
+## Documentation
+
+The EmDash docs are available as an MCP server at `https://docs.emdashcms.com/mcp`. When you need to verify an API, hook, config option, field type, or pattern, call `search_docs` against the live documentation rather than relying on training-data recall. The docs reflect current behaviour; assumptions may not.
+
+This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json` so Claude Code, Cursor, and VS Code auto-discover the docs server. Other tools (OpenCode, Windsurf, etc.) need a manual one-time setup -- see [docs.emdashcms.com/docs-mcp](https://docs.emdashcms.com/docs-mcp).
+
## Rules
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
diff --git a/blog/package.json b/blog/package.json
index d2dd82a..c29583c 100644
--- a/blog/package.json
+++ b/blog/package.json
@@ -11,17 +11,15 @@
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
- "bootstrap": "emdash init && emdash seed",
- "seed": "emdash seed",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/node": "^10.0.0",
"@astrojs/react": "^5.0.0",
- "@emdash-cms/plugin-audit-log": "^0.1.2",
+ "@emdash-cms/plugin-audit-log": "^0.1.3",
"astro": "^6.0.1",
"better-sqlite3": "^12.8.0",
- "emdash": "^0.9.0",
+ "emdash": "^0.11.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
diff --git a/blog/src/layouts/Base.astro b/blog/src/layouts/Base.astro
index df078d8..67c46e3 100644
--- a/blog/src/layouts/Base.astro
+++ b/blog/src/layouts/Base.astro
@@ -127,14 +127,14 @@ const isLoggedIn = !!Astro.locals.user;
))
}
- {
- isLoggedIn && (
-
- Admin
-
- )
- }
+ {
+ isLoggedIn && (
+
+ Admin
+
+ )
+ }
@@ -620,6 +620,7 @@ const isLoggedIn = !!Astro.locals.user;
display: flex;
align-items: center;
gap: var(--spacing-6);
+ margin-inline-start: auto;
}
.nav-links {
@@ -928,37 +929,43 @@ const isLoggedIn = !!Astro.locals.user;
}
.nav {
- flex-direction: row;
- flex-wrap: wrap;
- justify-content: space-between;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: auto;
- gap: var(--spacing-2);
+ column-gap: var(--spacing-3);
+ row-gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
.site-title {
+ grid-column: 1;
+ grid-row: 1;
font-size: var(--font-size-base);
+ min-width: 0;
}
.nav-right {
- display: contents;
+ grid-column: 1 / -1;
+ grid-row: 2;
+ display: grid;
+ row-gap: var(--spacing-2);
}
.site-search {
- order: 0;
- max-width: 140px;
+ grid-column: 1;
+ width: 100%;
+ max-width: none;
}
:global(.site-search-input) {
- width: 140px !important;
+ width: 100% !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}
.nav-links {
- order: 1;
- width: 100%;
+ grid-column: 1;
display: flex;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
@@ -967,11 +974,12 @@ const isLoggedIn = !!Astro.locals.user;
}
.nav-admin {
- order: 2;
- position: absolute;
- right: var(--spacing-4);
- top: var(--spacing-3);
+ grid-column: 2;
+ grid-row: 1;
+ justify-self: end;
font-size: var(--font-size-xs);
+ margin-inline-start: 0;
+ white-space: nowrap;
}
.footer-grid {
diff --git a/blog/src/pages/category/[slug].astro b/blog/src/pages/category/[slug].astro
index 6920785..f0bbf70 100644
--- a/blog/src/pages/category/[slug].astro
+++ b/blog/src/pages/category/[slug].astro
@@ -21,7 +21,7 @@ const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
diff --git a/blog/src/pages/index.astro b/blog/src/pages/index.astro
index 790c0c0..da6b71c 100644
--- a/blog/src/pages/index.astro
+++ b/blog/src/pages/index.astro
@@ -23,7 +23,7 @@ const [{ entries: posts, cacheHint }, settings] = await Promise.all([
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
diff --git a/blog/src/pages/pages/[slug].astro b/blog/src/pages/pages/[slug].astro
index 2e81625..3c15fea 100644
--- a/blog/src/pages/pages/[slug].astro
+++ b/blog/src/pages/pages/[slug].astro
@@ -15,7 +15,7 @@ if (!page) {
return Astro.redirect("/404");
}
-Astro.cache.set(cacheHint);
+if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
{
- // Trusted: ctx.http present because descriptor declares network:fetch
+ // Trusted: ctx.http present because descriptor declares network:request
// Sandboxed: ctx.http present and enforced via RPC bridge
if (!ctx.http) return;
await ctx.http.fetch("https://api.analytics.example.com/track", {
@@ -180,25 +180,27 @@ Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path`
Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution.
-| Capability | Grants | `ctx` property |
-| ----------------- | ---------------------------------------------------------------------- | -------------- |
-| `read:content` | `ctx.content.get()`, `ctx.content.list()` | `content` |
-| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
-| `read:media` | `ctx.media.get()`, `ctx.media.list()` | `media` |
-| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
-| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
-| `read:users` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
-| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
-| `email:provide` | Can register `email:deliver` exclusive hook (transport provider) | — |
-| `email:intercept` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| Capability | Grants | `ctx` property |
+| -------------------------------- | ---------------------------------------------------------------------- | -------------- |
+| `content:read` | `ctx.content.get()`, `ctx.content.list()` | `content` |
+| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` |
+| `media:read` | `ctx.media.get()`, `ctx.media.list()` | `media` |
+| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` |
+| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` |
+| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) | `http` |
+| `users:read` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` |
+| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` |
+| `hooks.email-transport:register` | Can register `email:deliver` exclusive hook (transport provider) | — |
+| `hooks.email-events:register` | Can register `email:beforeSend` / `email:afterSend` hooks | — |
+| `hooks.page-fragments:register` | Can register `page:fragments` hook (inject scripts/styles into pages) | — |
Storage (`ctx.storage`) and KV (`ctx.kv`) are **always available** — no capability needed. They're automatically scoped to the plugin.
**Email capabilities are distinct:**
- `email:send` — for plugins that _consume_ email (call `ctx.email.send()`)
-- `email:provide` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
-- `email:intercept` — for plugins that _observe or transform_ email (middleware hooks)
+- `hooks.email-transport:register` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP)
+- `hooks.email-events:register` — for plugins that _observe or transform_ email (middleware hooks)
```typescript
// In the descriptor (index.ts)
@@ -209,7 +211,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com", "*.googleapis.com"], // Wildcards supported
};
}
@@ -296,7 +298,7 @@ export function submissionsPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/plugin-submissions/sandbox",
options: {},
- capabilities: ["read:content"],
+ capabilities: ["content:read"],
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
@@ -415,10 +417,10 @@ interface PluginContext {
storage: Record; // Declared collections
kv: KVAccess; // Key-value store
log: LogAccess; // Structured logger
- content?: ContentAccess; // If "read:content" capability
- media?: MediaAccess; // If "read:media" capability
- http?: HttpAccess; // If "network:fetch" capability
- users?: UserAccess; // If "read:users" capability
+ content?: ContentAccess; // If "content:read" capability
+ media?: MediaAccess; // If "media:read" capability
+ http?: HttpAccess; // If "network:request" capability
+ users?: UserAccess; // If "users:read" capability
cron?: CronAccess; // Always available — scoped to plugin
email?: EmailAccess; // If "email:send" capability AND a provider is configured
}
@@ -435,7 +437,7 @@ export function myPlugin(): PluginDescriptor {
format: "standard",
entrypoint: "@my-org/my-plugin/sandbox",
options: {},
- capabilities: ["read:content", "network:fetch"],
+ capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
storage: { events: { indexes: ["timestamp"] } },
};
diff --git a/marketing-cloudflare/.agents/skills/creating-plugins/references/api-routes.md b/marketing-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
index c836fd6..c4d2419 100644
--- a/marketing-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
+++ b/marketing-cloudflare/.agents/skills/creating-plugins/references/api-routes.md
@@ -215,11 +215,11 @@ routes: {
### External API Proxy
-Requires `network:fetch` capability and `allowedHosts`:
+Requires `network:request` capability and `allowedHosts`:
```typescript
definePlugin({
- capabilities: ["network:fetch"],
+ capabilities: ["network:request"],
allowedHosts: ["api.weather.example.com"],
routes: {
diff --git a/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md b/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
index 31dec47..c8ba16e 100644
--- a/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
+++ b/marketing-cloudflare/.agents/skills/creating-plugins/references/hooks.md
@@ -163,6 +163,32 @@ Runs after successful delete.
Event: `{ id: string, collection: string }`
Returns: `void`
+### `content:afterPublish`
+
+Runs after content is published (promoted from draft to live). Side effects only.
+
+```typescript
+"content:afterPublish": async (event, ctx) => {
+ ctx.log.info(`Published ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
+### `content:afterUnpublish`
+
+Runs after content is unpublished (reverted to draft). Side effects only.
+
+```typescript
+"content:afterUnpublish": async (event, ctx) => {
+ ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
+}
+```
+
+Event: `{ content: Record, collection: string }`
+Returns: `void`
+
## Media Hooks
### `media:beforeUpload`
@@ -207,14 +233,14 @@ Email hooks require specific capabilities. Without the required capability, hook
### `email:beforeSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
definePlugin({
id: "email-footer",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:beforeSend": async (event, ctx) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
@@ -228,14 +254,14 @@ Returns: `EmailMessage | false`
### `email:deliver`
-**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.
+**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
definePlugin({
id: "emdash-resend",
- capabilities: ["email:provide", "network:fetch"],
+ capabilities: ["hooks.email-transport:register", "network:request"],
allowedHosts: ["api.resend.com"],
hooks: {
"email:deliver": {
@@ -258,14 +284,14 @@ Returns: `void`
### `email:afterSend`
-**Requires:** `email:intercept` capability.
+**Requires:** `hooks.email-events:register` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
definePlugin({
id: "email-logger",
- capabilities: ["email:intercept"],
+ capabilities: ["hooks.email-events:register"],
hooks: {
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
@@ -392,21 +418,23 @@ Use `"continue"` for non-critical operations (analytics, notifications, external
## Quick Reference
-| Hook | Trigger | Capability Required | Return |
-| ---------------------- | -------------------- | ------------------- | ---------------------------- |
-| `plugin:install` | First install | — | `void` |
-| `plugin:activate` | Plugin enabled | — | `void` |
-| `plugin:deactivate` | Plugin disabled | — | `void` |
-| `plugin:uninstall` | Plugin removed | — | `void` |
-| `content:beforeSave` | Before save | — | Modified content or `void` |
-| `content:afterSave` | After save | — | `void` |
-| `content:beforeDelete` | Before delete | — | `false` to cancel |
-| `content:afterDelete` | After delete | — | `void` |
-| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
-| `media:afterUpload` | After upload | — | `void` |
-| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` |
-| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) |
-| `email:afterSend` | After email send | `email:intercept` | `void` |
-| `cron` | Scheduled task fires | — | `void` |
-| `page:metadata` | Page render | — | Metadata contributions |
-| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
+| Hook | Trigger | Capability Required | Return |
+| ------------------------ | -------------------- | -------------------------------- | ---------------------------- |
+| `plugin:install` | First install | — | `void` |
+| `plugin:activate` | Plugin enabled | — | `void` |
+| `plugin:deactivate` | Plugin disabled | — | `void` |
+| `plugin:uninstall` | Plugin removed | — | `void` |
+| `content:beforeSave` | Before save | `content:write` | Modified content or `void` |
+| `content:afterSave` | After save | `content:read` | `void` |
+| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel |
+| `content:afterDelete` | After delete | `content:read` | `void` |
+| `content:afterPublish` | After publish | `content:read` | `void` |
+| `content:afterUnpublish` | After unpublish | `content:read` | `void` |
+| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
+| `media:afterUpload` | After upload | — | `void` |
+| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` |
+| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) |
+| `email:afterSend` | After email send | `hooks.email-events:register` | `void` |
+| `cron` | Scheduled task fires | — | `void` |
+| `page:metadata` | Page render | — | Metadata contributions |
+| `page:fragments` | Page render | — (trusted only) | Fragment contributions |
diff --git a/marketing-cloudflare/.agents/skills/emdash-cli/SKILL.md b/marketing-cloudflare/.agents/skills/emdash-cli/SKILL.md
index 8052de7..5386e09 100644
--- a/marketing-cloudflare/.agents/skills/emdash-cli/SKILL.md
+++ b/marketing-cloudflare/.agents/skills/emdash-cli/SKILL.md
@@ -70,22 +70,21 @@ npx emdash login --url https://example.com -H "X-API-Key: secret123"
### Database Setup
-```bash
-# Initialize database with migrations
-npx emdash init
+Migrations and seed application happen automatically inside the runtime — there's no separate init/seed step. Just start the dev server (or deploy) and the first request runs pending migrations and applies the bundled seed if the database is empty.
-# Start dev server (runs migrations, starts Astro)
+```bash
+# Start dev server (runs migrations, applies seed on empty DB, starts Astro)
npx emdash dev
# Start dev server and generate types from remote
npx emdash dev --types
-# Apply a seed file
-npx emdash seed .emdash/seed.json
-
-# Export database as seed
-npx emdash export-seed > seed.json
-npx emdash export-seed --with-content > seed.json
+# Export an existing database as a seed file
+# (the runtime auto-discovers .emdash/seed.json on first boot;
+# `mkdir -p` because the directory may not exist yet)
+mkdir -p .emdash
+npx emdash export-seed > .emdash/seed.json
+npx emdash export-seed --with-content > .emdash/seed.json
```
### Type Generation
@@ -177,7 +176,7 @@ npx emdash schema add-field posts featured --type boolean --required
npx emdash schema remove-field posts featured
```
-Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `image`, `reference`, `portableText`, `json`.
+Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `image`, `file`, `reference`, `portableText`, `json`, `slug`, `url`. See `FIELD_TYPE_TO_COLUMN` in `packages/core/src/schema/types.ts` for the authoritative list.
### Media
diff --git a/marketing-cloudflare/.cursor/mcp.json b/marketing-cloudflare/.cursor/mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/marketing-cloudflare/.cursor/mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/marketing-cloudflare/.gitignore b/marketing-cloudflare/.gitignore
index 450e13a..1cc5052 100644
--- a/marketing-cloudflare/.gitignore
+++ b/marketing-cloudflare/.gitignore
@@ -23,7 +23,8 @@ uploads/
.dev.vars.*
# IDE
-.vscode/
+.vscode/*
+!.vscode/mcp.json
.idea/
*.swp
*.swo
diff --git a/marketing-cloudflare/.mcp.json b/marketing-cloudflare/.mcp.json
new file mode 100644
index 0000000..7dcebfa
--- /dev/null
+++ b/marketing-cloudflare/.mcp.json
@@ -0,0 +1,8 @@
+{
+ "mcpServers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/marketing-cloudflare/.vscode/mcp.json b/marketing-cloudflare/.vscode/mcp.json
new file mode 100644
index 0000000..1401a6f
--- /dev/null
+++ b/marketing-cloudflare/.vscode/mcp.json
@@ -0,0 +1,8 @@
+{
+ "servers": {
+ "emdash-docs": {
+ "type": "http",
+ "url": "https://docs.emdashcms.com/mcp"
+ }
+ }
+}
diff --git a/marketing-cloudflare/AGENTS.md b/marketing-cloudflare/AGENTS.md
index 9524aad..9af5138 100644
--- a/marketing-cloudflare/AGENTS.md
+++ b/marketing-cloudflare/AGENTS.md
@@ -5,7 +5,6 @@ This is an EmDash site -- a CMS built on Astro with a full admin UI.
```bash
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
npx emdash types # Regenerate TypeScript types from schema
-npx emdash seed seed/seed.json --validate # Validate seed file
```
The admin UI is at `http://localhost:4321/_emdash/admin`.
@@ -29,6 +28,12 @@ Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
+## Documentation
+
+The EmDash docs are available as an MCP server at `https://docs.emdashcms.com/mcp`. When you need to verify an API, hook, config option, field type, or pattern, call `search_docs` against the live documentation rather than relying on training-data recall. The docs reflect current behaviour; assumptions may not.
+
+This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json` so Claude Code, Cursor, and VS Code auto-discover the docs server. Other tools (OpenCode, Windsurf, etc.) need a manual one-time setup -- see [docs.emdashcms.com/docs-mcp](https://docs.emdashcms.com/docs-mcp).
+
## Rules
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
diff --git a/marketing-cloudflare/package.json b/marketing-cloudflare/package.json
index 545f66f..1201f81 100644
--- a/marketing-cloudflare/package.json
+++ b/marketing-cloudflare/package.json
@@ -11,25 +11,23 @@
"build": "astro build",
"preview": "astro preview",
"deploy": "astro build && wrangler deploy",
- "typecheck": "astro check",
- "bootstrap": "emdash init && emdash seed",
- "seed": "emdash seed"
+ "typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "^13.1.7",
"@astrojs/react": "^5.0.0",
- "@emdash-cms/cloudflare": "^0.9.0",
+ "@emdash-cms/cloudflare": "^0.11.0",
"@iconify-json/ph": "^1.2.2",
"astro": "^6.0.1",
"astro-iconset": "^0.0.4",
- "emdash": "^0.9.0",
+ "emdash": "^0.11.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.7",
"@cloudflare/workers-types": "^4.20260305.1",
- "wrangler": "^4.80.0"
+ "wrangler": "^4.83.0"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/marketing-cloudflare/seed/seed.json b/marketing-cloudflare/seed/seed.json
index 8715490..c166c78 100644
--- a/marketing-cloudflare/seed/seed.json
+++ b/marketing-cloudflare/seed/seed.json
@@ -52,6 +52,33 @@
"url": "/contact"
}
]
+ },
+ {
+ "name": "footer_product",
+ "label": "Footer: Product",
+ "items": [
+ { "type": "custom", "label": "Features", "url": "/#features" },
+ { "type": "custom", "label": "Pricing", "url": "/pricing" },
+ { "type": "custom", "label": "Changelog", "url": "/changelog" }
+ ]
+ },
+ {
+ "name": "footer_company",
+ "label": "Footer: Company",
+ "items": [
+ { "type": "custom", "label": "About", "url": "/about" },
+ { "type": "custom", "label": "Blog", "url": "/blog" },
+ { "type": "custom", "label": "Careers", "url": "/careers" }
+ ]
+ },
+ {
+ "name": "footer_support",
+ "label": "Footer: Support",
+ "items": [
+ { "type": "custom", "label": "Documentation", "url": "/docs" },
+ { "type": "custom", "label": "Contact", "url": "/contact" },
+ { "type": "custom", "label": "Status", "url": "/status" }
+ ]
}
],
"content": {
diff --git a/marketing-cloudflare/src/layouts/Base.astro b/marketing-cloudflare/src/layouts/Base.astro
index cde7372..428ee80 100644
--- a/marketing-cloudflare/src/layouts/Base.astro
+++ b/marketing-cloudflare/src/layouts/Base.astro
@@ -20,7 +20,18 @@ const siteDescription =
const siteLogo = (settings?.logo as any)?.url ? settings.logo as
{ mediaId: string; alt?: string; url: string } : null;
-const menu = await getMenu("primary");
+const [menu, footerProduct, footerCompany, footerSupport] = await Promise.all([
+ getMenu("primary"),
+ getMenu("footer_product"),
+ getMenu("footer_company"),
+ getMenu("footer_support"),
+]);
+
+const footerColumns = [
+ { heading: "Product", menu: footerProduct },
+ { heading: "Company", menu: footerCompany },
+ { heading: "Support", menu: footerSupport },
+].filter((col) => col.menu && col.menu.items.length > 0);
const pageCtx = createPublicPageContext({
Astro,
@@ -101,24 +112,21 @@ const pageCtx = createPublicPageContext({