docs: audit and fix documentation for release (#230)

* docs: fix critical errors in import paths, types, and API references

- Fix Cloudflare adapter imports: d1/r2 come from @emdash-cms/cloudflare, not emdash/db or emdash/astro
- Fix PortableText import path: emdash/ui, not emdash/astro
- Replace set:html with PortableText component for Portable Text content
- Fix CLI binary alias: em, not ec
- Fix media upload API: POST multipart to /api/media, not JSON to /api/media/upload
- Fix MediaValue type: src not url, provider is optional, add previewUrl
- Fix EmDashMedia to Image component (actual export name)
- Fix Cloudflare Access auth config: use access() function, not nested object
- Fix REST API methods: content/media update is PUT not PATCH, settings is POST not PUT
- Fix contributing docs: Node.js 22+, pnpm 10+, correct E2E test command
- Fix WordPress migration: remove undocumented CLI import command

* docs: fix high-priority technical errors across docs

- Fix hook names: beforeSave/afterSave, not beforeCreate/afterCreate
- Fix status values: draft/published/scheduled, not archived
- Fix field type count: 14, not 15
- Fix MCP tool count: 33, not 28
- Fix Section.previewUrl type: string, not object
- Fix getSections examples to show { items } destructuring
- Add missing CollectionSupport values: search, seo
- Update reserved field slugs to match actual code
- Add MCP server enablement note (mcp: true required)
- Clarify getStaticPaths guidance: themes must be SSR, other sites can use static
- Delete orphaned duplicate migration/plugin-porting.mdx

* docs: fix medium-priority issues across docs

- Fix broken internal links: /guides/media/ -> /guides/media-library/, /guides/seeding/ -> /themes/seed-files/
- Standardize env var to EMDASH_PREVIEW_SECRET throughout preview guide
- Fix featuredImage -> featured_image in widgets guide
- Remove Discord social link (no Discord server exists)
- Fix formatting config reference: .oxfmtrc.json, not .prettierrc
- Add audienceEnvVar to Cloudflare Access config options
- Fix content model type declarations to show actual return types

* docs: document missing plugin hooks, capabilities, and context properties

- Add 10 missing hooks to reference: cron, email (beforeSend, deliver, afterSend),
  comment (beforeCreate, moderate, afterCreate, afterModerate), page (metadata, fragments)
- Document all hook event types, handler signatures, and return values
- Add exclusive hook option to configuration tables
- Add 6 missing capabilities: network:fetch:any, read:users, email:send/provide/intercept, page:inject
- Add 6 missing context properties: ctx.site, ctx.url(), ctx.users, ctx.cron, ctx.email
- Update hooks reference tables in both plugins/hooks.mdx and reference/hooks.mdx

* Format
This commit is contained in:
Matt Kane
2026-04-04 09:17:00 +01:00
committed by GitHub
parent 036e0fc23a
commit 9ebc8b1f3f
28 changed files with 527 additions and 820 deletions

View File

@@ -3,6 +3,6 @@
--- ---
Optimizes D1 database indexes to eliminate full table scans in admin panel. Adds Optimizes D1 database indexes to eliminate full table scans in admin panel. Adds
composite indexes on ec_\* content tables for common query patterns (deleted_at + composite indexes on ec\_\* content tables for common query patterns (deleted_at +
updated_at/created_at + id) and rewrites comment counting to use partial indexes. updated_at/created_at + id) and rewrites comment counting to use partial indexes.
Reduces D1 row reads by 90%+ for dashboard operations. Reduces D1 row reads by 90%+ for dashboard operations.

View File

@@ -19,11 +19,6 @@ export default defineConfig({
label: "GitHub", label: "GitHub",
href: "https://github.com/emdash-cms/emdash", href: "https://github.com/emdash-cms/emdash",
}, },
{
icon: "discord",
label: "Discord",
href: "https://astro.build/chat",
},
], ],
editLink: { editLink: {
baseUrl: "https://github.com/emdash-cms/emdash/tree/main/docs", baseUrl: "https://github.com/emdash-cms/emdash/tree/main/docs",

View File

@@ -69,7 +69,7 @@ CREATE TABLE ec_posts (
-- System columns (always present) -- System columns (always present)
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
slug TEXT UNIQUE, slug TEXT UNIQUE,
status TEXT DEFAULT 'draft', -- draft, published, archived status TEXT DEFAULT 'draft', -- draft, published, scheduled
author_id TEXT, author_id TEXT,
created_at TEXT DEFAULT (datetime('now')), created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')),
@@ -198,8 +198,8 @@ Uploads use signed URLs for direct client-to-storage uploads, bypassing Workers
Plugins extend EmDash through a WordPress-inspired hook system: Plugins extend EmDash through a WordPress-inspired hook system:
- **Content hooks** — `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete` - **Content hooks** — `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, `content:afterDelete`
- **Media hooks** — `beforeMediaUpload`, `afterMediaUpload` - **Media hooks** — `media:beforeUpload`, `media:afterUpload`
- **Isolated storage** — Each plugin gets namespaced KV access - **Isolated storage** — Each plugin gets namespaced KV access
- **Admin UI extensions** — Dashboard widgets, settings pages, custom field editors - **Admin UI extensions** — Dashboard widgets, settings pages, custom field editors

View File

@@ -21,7 +21,7 @@ Create collections through the admin panel under **Content Types**. Each collect
| `labelSingular` | Singular form (e.g., "Post") | | `labelSingular` | Singular form (e.g., "Post") |
| `description` | Optional description for editors | | `description` | Optional description for editors |
| `icon` | Lucide icon name for the admin sidebar | | `icon` | Lucide icon name for the admin sidebar |
| `supports` | Features like drafts, revisions, preview, scheduling | | `supports` | Features like drafts, revisions, preview, scheduling, search, seo |
<Aside type="note"> <Aside type="note">
Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`, Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`,
@@ -223,7 +223,8 @@ Every field supports these properties:
<Aside type="caution"> <Aside type="caution">
Some field slugs are reserved and cannot be used: `id`, `slug`, `status`, `author_id`, Some field slugs are reserved and cannot be used: `id`, `slug`, `status`, `author_id`,
`created_at`, `updated_at`, `published_at`, `deleted_at`, `version`. `primary_byline_id`, `created_at`, `updated_at`, `published_at`, `scheduled_at`, `deleted_at`,
`version`, `live_revision_id`, `draft_revision_id`.
</Aside> </Aside>
## Validation Rules ## Validation Rules
@@ -376,6 +377,6 @@ Field types map to SQLite column types:
Organize content with [categories and tags](/guides/taxonomies/). Organize content with [categories and tags](/guides/taxonomies/).
</Card> </Card>
<Card title="Media Library" icon="seti:image"> <Card title="Media Library" icon="seti:image">
Manage [images and files](/guides/media/). Manage [images and files](/guides/media-library/).
</Card> </Card>
</CardGrid> </CardGrid>

View File

@@ -230,12 +230,14 @@ export interface Product {
// Typed overloads for query functions // Typed overloads for query functions
declare module "emdash" { declare module "emdash" {
export function getEmDashCollection(type: "posts"): Promise<ContentEntry<Post>[]>; export function getEmDashCollection(
type: "posts",
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
export function getEmDashEntry( export function getEmDashEntry(
type: "products", type: "products",
id: string, id: string,
): Promise<ContentEntry<Product> | null>; ): Promise<{ entry: ContentEntry<Product> | null; error?: Error; isPreview: boolean }>;
} }
``` ```
@@ -328,6 +330,6 @@ EmDash follows the Directus model: database-first with optional type generation.
Explore the [admin architecture](/concepts/admin-panel/). Explore the [admin architecture](/concepts/admin-panel/).
</Card> </Card>
<Card title="Seeding" icon="open-book"> <Card title="Seeding" icon="open-book">
Set up sites with [seed files](/guides/seeding/). Set up sites with [seed files](/themes/seed-files/).
</Card> </Card>
</CardGrid> </CardGrid>

View File

@@ -61,8 +61,8 @@ packages/core/src/
## Prerequisites ## Prerequisites
- **Node.js** 20 or higher - **Node.js** 22 or higher
- **pnpm** 9 or higher - **pnpm** 10 or higher
- **Git** - **Git**
```bash ```bash
@@ -151,7 +151,7 @@ pnpm --filter emdash test --watch
</TabItem> </TabItem>
<TabItem label="E2E tests"> <TabItem label="E2E tests">
```bash ```bash
pnpm --filter emdash-demo test:e2e pnpm test:e2e
``` ```
</TabItem> </TabItem>
</Tabs> </Tabs>
@@ -182,7 +182,7 @@ pnpm lint:json
pnpm format pnpm format
``` ```
EmDash uses **oxfmt** (Oxc formatter). The config is in `.prettierrc` (oxfmt uses the same config file format). Tabs, not spaces. EmDash uses **oxfmt** (Oxc formatter). The config is in `.oxfmtrc.json`. Tabs, not spaces.
## Architecture Overview ## Architecture Overview

View File

@@ -64,8 +64,8 @@ Update your Astro configuration to use D1 and R2:
```js title="astro.config.mjs" ```js title="astro.config.mjs"
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare"; import cloudflare from "@astrojs/cloudflare";
import emdash, { r2 } from "emdash/astro"; import emdash from "emdash/astro";
import { d1 } from "emdash/db"; import { d1, r2 } from "@emdash-cms/cloudflare";
export default defineConfig({ export default defineConfig({
output: "server", output: "server",
@@ -170,16 +170,14 @@ If your organization uses Cloudflare Access, you can use it as your authenticati
emdash({ emdash({
database: d1({ binding: "DB" }), database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }), storage: r2({ binding: "MEDIA" }),
auth: { auth: access({
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com", teamDomain: "myteam.cloudflareaccess.com",
audience: "your-app-audience-tag", audience: "your-app-audience-tag",
roleMapping: { roleMapping: {
"Admins": 50, "Admins": 50,
"Editors": 40, "Editors": 40,
}, },
}, }),
},
}), }),
``` ```

View File

@@ -21,7 +21,7 @@ EmDash supports multiple database backends. Choose based on your deployment targ
D1 is Cloudflare's serverless SQLite database. Use it when deploying to Cloudflare Workers. D1 is Cloudflare's serverless SQLite database. Use it when deploying to Cloudflare Workers.
```js title="astro.config.mjs" ```js title="astro.config.mjs"
import { d1 } from "emdash/db"; import { d1 } from "@emdash-cms/cloudflare";
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [
@@ -301,7 +301,8 @@ wrangler d1 migrations apply DB
Use different databases per environment: Use different databases per environment:
```js title="astro.config.mjs" ```js title="astro.config.mjs"
import { d1, sqlite, libsql, postgres } from "emdash/db"; import { sqlite, libsql, postgres } from "emdash/db";
import { d1 } from "@emdash-cms/cloudflare";
const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" }); const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });

View File

@@ -20,7 +20,8 @@ EmDash stores uploaded media (images, documents, videos) in a configurable stora
Use R2 bindings when deploying to Cloudflare Workers for the fastest integration. Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.
```js title="astro.config.mjs" ```js title="astro.config.mjs"
import emdash, { r2 } from "emdash/astro"; import emdash from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";
export default defineConfig({ export default defineConfig({
integrations: [ integrations: [
@@ -188,7 +189,8 @@ The `baseUrl` should match EmDash's media file endpoint (`/_emdash/api/media/fil
Switch storage backends based on environment: Switch storage backends based on environment:
```js title="astro.config.mjs" ```js title="astro.config.mjs"
import emdash, { r2, s3, local } from "emdash/astro"; import emdash, { s3, local } from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";
const storage = import.meta.env.PROD const storage = import.meta.env.PROD
? r2({ binding: "MEDIA" }) ? r2({ binding: "MEDIA" })

View File

@@ -7,6 +7,16 @@ import { Aside, Steps, Tabs, TabItem, LinkCard } from "@astrojs/starlight/compon
EmDash has a built-in [MCP server](https://modelcontextprotocol.io) that lets AI assistants work directly with your site's content. You can ask Claude, ChatGPT, or other tools to draft posts, update pages, manage media, search your content, and more -- all through natural conversation. EmDash has a built-in [MCP server](https://modelcontextprotocol.io) that lets AI assistants work directly with your site's content. You can ask Claude, ChatGPT, or other tools to draft posts, update pages, manage media, search your content, and more -- all through natural conversation.
## Enable the MCP Server
The MCP server is disabled by default. Enable it in your Astro configuration:
```js title="astro.config.mjs"
emdash({
mcp: true,
})
```
## Setting Up ## Setting Up
Your site's MCP server URL is: Your site's MCP server URL is:

View File

@@ -241,7 +241,7 @@ When deploying to Cloudflare, you can use [Cloudflare Access](https://developers
import { defineConfig } from "astro/config"; import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare"; import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro"; import emdash from "emdash/astro";
import { d1 } from "emdash/db"; import { d1, access } from "@emdash-cms/cloudflare";
export default defineConfig({ export default defineConfig({
output: "server", output: "server",
@@ -249,12 +249,10 @@ export default defineConfig({
integrations: [ integrations: [
emdash({ emdash({
database: d1({ binding: "DB" }), database: d1({ binding: "DB" }),
auth: { auth: access({
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com", teamDomain: "myteam.cloudflareaccess.com",
audience: "abc123def456...", // From Access app settings audience: "abc123def456...", // From Access app settings
}, }),
},
}), }),
], ],
}); });
@@ -270,6 +268,7 @@ export default defineConfig({
| `defaultRole` | `number` | `30` | Role for users not matching any group (30 = Author) | | `defaultRole` | `number` | `30` | Role for users not matching any group (30 = Author) |
| `syncRoles` | `boolean` | `false` | Update role on each login based on IdP groups | | `syncRoles` | `boolean` | `false` | Update role on each login based on IdP groups |
| `roleMapping` | `object` | — | Map IdP group names to role levels | | `roleMapping` | `object` | — | Map IdP group names to role levels |
| `audienceEnvVar`| `string` | `"CF_ACCESS_AUDIENCE"` | Environment variable name for the audience tag (alternative to hardcoding) |
### Role Mapping ### Role Mapping
@@ -277,8 +276,7 @@ Map your IdP groups to EmDash roles:
```js title="astro.config.mjs" ```js title="astro.config.mjs"
emdash({ emdash({
auth: { auth: access({
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com", teamDomain: "myteam.cloudflareaccess.com",
audience: "abc123...", audience: "abc123...",
roleMapping: { roleMapping: {
@@ -287,8 +285,7 @@ emdash({
Writers: 30, // Author Writers: 30, // Author
}, },
defaultRole: 20, // Contributor for users not in any group defaultRole: 20, // Contributor for users not in any group
}, }),
},
}); });
``` ```

View File

@@ -25,7 +25,7 @@ The default posts collection includes:
- `content` - Rich text body - `content` - Rich text body
- `excerpt` - Short description - `excerpt` - Short description
- `featured_image` - Header image (optional) - `featured_image` - Header image (optional)
- `status` - draft, published, or archived - `status` - draft, published, or scheduled
- `publishedAt` - Publication date (system field) - `publishedAt` - Publication date (system field)
## Create Your First Post ## Create Your First Post
@@ -96,6 +96,7 @@ Create a dynamic route for individual posts:
```astro title="src/pages/blog/[slug].astro" ```astro title="src/pages/blog/[slug].astro"
--- ---
import { getEmDashCollection, getEmDashEntry } from "emdash"; import { getEmDashCollection, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
export async function getStaticPaths() { export async function getStaticPaths() {
@@ -125,7 +126,7 @@ if (!post) {
<time datetime={post.data.publishedAt?.toISOString()}> <time datetime={post.data.publishedAt?.toISOString()}>
{post.data.publishedAt?.toLocaleDateString()} {post.data.publishedAt?.toLocaleDateString()}
</time> </time>
<div set:html={post.data.content} /> <PortableText value={post.data.content} />
</article> </article>
</Base> </Base>
``` ```

View File

@@ -285,41 +285,32 @@ Access media programmatically using the admin API.
### Upload a File ### Upload a File
Request a signed upload URL: Upload media as multipart form data:
```bash ```bash
POST /_emdash/api/media/upload POST /_emdash/api/media
Content-Type: application/json Content-Type: multipart/form-data
Authorization: Bearer YOUR_API_TOKEN Authorization: Bearer YOUR_API_TOKEN
{ file=<binary file data>
"filename": "hero-image.jpg",
"contentType": "image/jpeg",
"size": 245000
}
``` ```
Response: Response:
```json ```json
{ {
"url": "https://storage.example.com/signed-upload-url...", "success": true,
"method": "PUT", "data": {
"headers": { "item": {
"Content-Type": "image/jpeg" "id": "01ABC123",
}, "filename": "hero-image.jpg",
"expiresAt": "2024-01-15T12:00:00Z", "mime_type": "image/jpeg",
"key": "media/abc123/hero-image.jpg" "storage_key": "media/abc123/hero-image.jpg",
"width": 1200,
"height": 800
}
}
} }
```
Upload the file using the signed URL:
```bash
PUT https://storage.example.com/signed-upload-url...
Content-Type: image/jpeg
<file contents>
``` ```
### List Media ### List Media
@@ -444,19 +435,19 @@ The local media library ("Library" tab) is always available alongside any config
### Rendering Provider Media ### Rendering Provider Media
Use the `EmDashMedia` component to render media from any provider: Use the `Image` component to render media:
```astro title="src/pages/posts/[slug].astro" ```astro title="src/pages/posts/[slug].astro"
--- ---
import { EmDashMedia } from "emdash/ui"; import { Image } from "emdash/ui";
import { getEmDashEntry } from "emdash"; import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug); const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
--- ---
{post?.data.featured_image && ( {post?.data.featured_image && (
<EmDashMedia <Image
value={post.data.featured_image} image={post.data.featured_image}
width={800} width={800}
height={450} height={450}
/> />
@@ -465,7 +456,7 @@ const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
The component automatically: The component automatically:
- Detects the provider from the stored value - Detects the provider from the stored value
- Renders the appropriate element (`<img>`, `<video>`, etc.) - Renders an optimized `<img>` element
- Applies provider-specific optimizations (e.g., Cloudflare Images transformations) - Applies provider-specific optimizations (e.g., Cloudflare Images transformations)
### MediaValue Type ### MediaValue Type
@@ -474,9 +465,10 @@ Media fields store a `MediaValue` object containing provider information:
```ts ```ts
interface MediaValue { interface MediaValue {
provider: string; // Provider ID (e.g., "local", "cloudflare-images") provider?: string; // Provider ID, defaults to "local"
id: string; // Provider-specific ID id: string; // Provider-specific ID
url?: string; // Direct URL (for local/external) src?: string; // Direct URL (for local media or legacy data)
previewUrl?: string; // Preview URL for admin display (external providers)
filename?: string; // Original filename filename?: string; // Original filename
mimeType?: string; // MIME type mimeType?: string; // MIME type
width?: number; // Image/video width width?: number; // Image/video width

View File

@@ -70,7 +70,7 @@ import { getPreviewUrl } from "emdash";
const previewUrl = await getPreviewUrl({ const previewUrl = await getPreviewUrl({
collection: "posts", collection: "posts",
id: "my-draft-post", id: "my-draft-post",
secret: process.env.PREVIEW_SECRET!, secret: import.meta.env.EMDASH_PREVIEW_SECRET,
expiresIn: "1h", expiresIn: "1h",
}); });
// Returns: /posts/my-draft-post?_preview=eyJjaWQ... // Returns: /posts/my-draft-post?_preview=eyJjaWQ...
@@ -82,7 +82,7 @@ With a base URL for absolute links:
const fullUrl = await getPreviewUrl({ const fullUrl = await getPreviewUrl({
collection: "posts", collection: "posts",
id: "my-draft-post", id: "my-draft-post",
secret: process.env.PREVIEW_SECRET!, secret: import.meta.env.EMDASH_PREVIEW_SECRET,
baseUrl: "https://example.com", baseUrl: "https://example.com",
}); });
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ... // Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...
@@ -94,7 +94,7 @@ With a custom path pattern:
const blogUrl = await getPreviewUrl({ const blogUrl = await getPreviewUrl({
collection: "posts", collection: "posts",
id: "my-draft-post", id: "my-draft-post",
secret: process.env.PREVIEW_SECRET!, secret: import.meta.env.EMDASH_PREVIEW_SECRET,
pathPattern: "/blog/{id}", pathPattern: "/blog/{id}",
}); });
// Returns: /blog/my-draft-post?_preview=eyJjaWQ... // Returns: /blog/my-draft-post?_preview=eyJjaWQ...
@@ -133,13 +133,13 @@ import { verifyPreviewToken } from "emdash";
// From a URL (extracts _preview query parameter) // From a URL (extracts _preview query parameter)
const result = await verifyPreviewToken({ const result = await verifyPreviewToken({
url: Astro.url, url: Astro.url,
secret: import.meta.env.PREVIEW_SECRET, secret: import.meta.env.EMDASH_PREVIEW_SECRET,
}); });
// Or with a token directly // Or with a token directly
const result = await verifyPreviewToken({ const result = await verifyPreviewToken({
token: someTokenString, token: someTokenString,
secret: import.meta.env.PREVIEW_SECRET, secret: import.meta.env.EMDASH_PREVIEW_SECRET,
}); });
``` ```
@@ -228,7 +228,7 @@ The payload contains:
Tokens are signed with HMAC-SHA256 using your preview secret. Tokens are signed with HMAC-SHA256 using your preview secret.
<Aside type="caution"> <Aside type="caution">
Keep your `PREVIEW_SECRET` secure. Anyone with this secret can generate valid preview tokens for Keep your `EMDASH_PREVIEW_SECRET` secure. Anyone with this secret can generate valid preview tokens for
any content. any content.
</Aside> </Aside>

View File

@@ -78,7 +78,7 @@ When `locale` is omitted, it defaults to the request's current locale. If no tra
### Filter by Status ### Filter by Status
Retrieve only published, draft, or archived content: Retrieve only published or draft content:
```ts ```ts
// Only published posts // Only published posts
@@ -90,16 +90,11 @@ const { entries: published } = await getEmDashCollection("posts", {
const { entries: drafts } = await getEmDashCollection("posts", { const { entries: drafts } = await getEmDashCollection("posts", {
status: "draft", status: "draft",
}); });
// Only archived
const { entries: archived } = await getEmDashCollection("posts", {
status: "archived",
});
``` ```
<Aside type="tip"> <Aside type="tip">
Always filter by `status: "published"` for public-facing pages. Draft and archived content should Always filter by `status: "published"` for public-facing pages. Draft content should only be
only be accessible in the admin or preview mode. accessible in the admin or preview mode.
</Aside> </Aside>
### Limit Results ### Limit Results
@@ -161,6 +156,7 @@ Use `getEmDashEntry` to retrieve one entry by its ID or slug:
```astro title="src/pages/posts/[slug].astro" ```astro title="src/pages/posts/[slug].astro"
--- ---
import { getEmDashEntry } from "emdash"; import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params; const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug); const { entry: post, error } = await getEmDashEntry("posts", slug);
@@ -176,7 +172,7 @@ if (!post) {
<article> <article>
<h1>{post.data.title}</h1> <h1>{post.data.title}</h1>
<div set:html={post.data.content} /> <PortableText value={post.data.content} />
</article> </article>
``` ```

View File

@@ -32,15 +32,17 @@ Fetch multiple sections with optional filters:
import { getSections } from "emdash"; import { getSections } from "emdash";
// Get all sections // Get all sections
const all = await getSections(); const { items: all } = await getSections();
// Filter by source // Filter by source
const themeSections = await getSections({ source: "theme" }); const { items: themeSections } = await getSections({ source: "theme" });
// Search by title/keywords // Search by title/keywords
const results = await getSections({ search: "newsletter" }); const { items: results } = await getSections({ search: "newsletter" });
``` ```
`getSections` returns `{ items: Section[], nextCursor?: string }` following the standard pagination pattern.
## Section Structure ## Section Structure
```typescript ```typescript
@@ -51,7 +53,7 @@ interface Section {
description?: string; description?: string;
keywords: string[]; keywords: string[];
content: PortableTextBlock[]; content: PortableTextBlock[];
previewMedia?: { id: string; url: string }; previewUrl?: string;
source: "theme" | "user" | "import"; source: "theme" | "user" | "import";
themeId?: string; themeId?: string;
createdAt: string; createdAt: string;

View File

@@ -202,8 +202,8 @@ const { entries: posts } = await getEmDashCollection("posts", {
<ul class="recent-posts"> <ul class="recent-posts">
{posts.map(post => ( {posts.map(post => (
<li> <li>
{showThumbnails && post.data.featuredImage && ( {showThumbnails && post.data.featured_image && (
<img src={post.data.featuredImage} alt="" class="thumbnail" /> <img src={post.data.featured_image} alt="" class="thumbnail" />
)} )}
<a href={`/posts/${post.slug}`}>{post.data.title}</a> <a href={`/posts/${post.slug}`}>{post.data.title}</a>
{showDate && post.data.publishedAt && ( {showDate && post.data.publishedAt && (

View File

@@ -217,22 +217,11 @@ Use this table when adapting WordPress patterns to EmDash:
| `$wpdb` | `ctx.storage` | Direct storage access | | `$wpdb` | `ctx.storage` | Direct storage access |
| Categories/Tags | Taxonomies | Hierarchical support preserved | | Categories/Tags | Taxonomies | Hierarchical support preserved |
## CLI Import (Advanced) ## API Import (Advanced)
Developers can also import via the CLI: The WordPress import is available through the admin dashboard and the REST API. Use the admin dashboard import wizard for the best experience — it provides field mapping, conflict resolution, and progress tracking.
```bash The import API endpoints are under `/_emdash/api/import/wordpress/` for programmatic access.
# Analyze export file
npx emdash import wordpress export.xml --analyze
# Run import
npx emdash import wordpress export.xml --execute
# With media download
npx emdash import wordpress export.xml --execute --download-media
```
The CLI uses the same APIs as the dashboard and supports `--resume` for interrupted imports.
## Troubleshooting ## Troubleshooting

View File

@@ -1,634 +0,0 @@
---
title: Porting WordPress Plugins
description: Migrate WordPress plugin functionality to EmDash plugins.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
WordPress plugins extend the CMS with custom functionality. EmDash provides equivalent extension points through its plugin system. This guide shows how to translate common WordPress patterns.
## Plugin Architecture Comparison
| WordPress | EmDash |
| ---------------------------------- | --------------------------------------- |
| PHP files in `wp-content/plugins/` | TypeScript modules registered in config |
| `add_action()` / `add_filter()` | Hook functions |
| Admin menu pages | Admin panel routes |
| REST API endpoints | API route handlers |
| Database via `$wpdb` | Storage via `ctx.storage` |
| Options via `wp_options` | Key-value via `ctx.kv` |
| Post meta | Collection fields |
| Shortcodes | Portable Text custom blocks |
| Gutenberg blocks | Portable Text custom blocks |
## Concept Mapping
### Actions and Filters → Hooks
WordPress uses `add_action()` and `add_filter()` for extensibility. EmDash uses typed hook functions.
<Tabs>
<TabItem label="WordPress">
```php
// WordPress action
add_action('save_post', function($post_id, $post) {
if ($post->post_type !== 'product') return;
update_post_meta($post_id, 'last_updated', time());
}, 10, 2);
// WordPress filter
add_filter('the_content', function($content) {
return $content . '<p>Read more articles</p>';
});
````
</TabItem>
<TabItem label="EmDash">
```typescript
// EmDash hook
export const hooks = {
'content:beforeSave': async (ctx, entry) => {
if (entry.collection !== 'products') return entry;
return {
...entry,
data: {
...entry.data,
lastUpdated: new Date().toISOString()
}
};
},
'content:afterRender': async (ctx, html) => {
return html + '<p>Read more articles</p>';
}
};
````
</TabItem>
</Tabs>
### Available Hooks
| Hook | Equivalent WordPress Hook | Purpose |
| ---------------------- | ---------------------------- | -------------------------- |
| `content:beforeSave` | `wp_insert_post_data` | Modify content before save |
| `content:afterSave` | `save_post` | React after content saved |
| `content:beforeDelete` | `before_delete_post` | Validate before deletion |
| `content:afterRender` | `the_content` | Transform rendered output |
| `media:beforeUpload` | `wp_handle_upload_prefilter` | Validate/transform uploads |
| `media:afterUpload` | `add_attachment` | React after upload |
| `admin:init` | `admin_init` | Admin panel initialization |
| `api:request` | `rest_pre_dispatch` | Intercept API requests |
### Database Access
WordPress uses `$wpdb` for direct database queries. EmDash provides `ctx.storage` for structured data access.
<Tabs>
<TabItem label="WordPress">
```php
global $wpdb;
// Insert
$wpdb->insert('custom_table', [
'name' => 'Example',
'value' => 42
]);
// Query
$results = $wpdb->get_results(
"SELECT \* FROM custom_table WHERE value > 10"
);
// Update
$wpdb->update('custom_table',
['value' => 50],
['name' => 'Example']
);
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Using ctx.storage (D1/SQLite)
const db = ctx.storage;
// Insert
await db.prepare(
'INSERT INTO custom_table (name, value) VALUES (?, ?)'
).bind('Example', 42).run();
// Query
const results = await db.prepare(
'SELECT * FROM custom_table WHERE value > ?'
).bind(10).all();
// Update
await db.prepare(
'UPDATE custom_table SET value = ? WHERE name = ?'
).bind(50, 'Example').run();
````
</TabItem>
</Tabs>
<Aside>
EmDash runs on Cloudflare Workers with D1 (SQLite). Use prepared statements with parameter
binding for security.
</Aside>
### Options Storage
WordPress uses `get_option()` / `update_option()`. EmDash uses `ctx.kv` for key-value storage.
<Tabs>
<TabItem label="WordPress">
```php
// Get option
$api_key = get_option('my_plugin_api_key', '');
// Set option
update_option('my_plugin_api_key', 'abc123');
// Delete option
delete_option('my_plugin_api_key');
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Get value
const apiKey = await ctx.kv.get('my_plugin_api_key') ?? '';
// Set value
await ctx.kv.put('my_plugin_api_key', 'abc123');
// Delete value
await ctx.kv.delete('my_plugin_api_key');
````
</TabItem>
</Tabs>
### Custom Post Types → Collections
WordPress registers post types with `register_post_type()`. EmDash uses collections defined in the admin UI or via API.
<Tabs>
<TabItem label="WordPress">
```php
register_post_type('product', [
'labels' => [
'name' => 'Products',
'singular_name' => 'Product'
],
'public' => true,
'supports' => ['title', 'editor', 'thumbnail'],
'has_archive' => true
]);
register_meta('post', 'price', [
'type' => 'number',
'single' => true,
'show_in_rest' => true
]);
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Create via API
await fetch('/_emdash/api/schema/collections', {
method: 'POST',
body: JSON.stringify({
slug: 'products',
label: 'Products',
labelSingular: 'Product',
fields: [
{ slug: 'title', type: 'string', required: true },
{ slug: 'content', type: 'portableText' },
{ slug: 'featuredImage', type: 'media' },
{ slug: 'price', type: 'number' }
]
})
});
````
</TabItem>
</Tabs>
Collections are typically created through the admin UI at **Content Types → New Content Type**.
### Shortcodes → Portable Text Blocks
WordPress shortcodes embed dynamic content. EmDash uses custom Portable Text blocks with React/Astro components.
<Tabs>
<TabItem label="WordPress">
```php
// Register shortcode
add_shortcode('product_card', function($atts) {
$atts = shortcode_atts([
'id' => 0,
'show_price' => true
], $atts);
$product = get_post($atts['id']);
$price = get_post_meta($atts['id'], 'price', true);
return sprintf(
'<div class="product-card">
<h3>%s</h3>
%s
</div>',
esc_html($product->post_title),
$atts['show_price'] ? '<p>$' . esc_html($price) . '</p>' : ''
);
});
// Usage in content: [product_card id="123" show_price="true"]
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Define Portable Text block schema
const productCardBlock = {
name: 'productCard',
type: 'object',
fields: [
{ name: 'productId', type: 'reference', to: 'products' },
{ name: 'showPrice', type: 'boolean', default: true }
]
};
// Render component (Astro)
---
// src/components/ProductCard.astro
import { getEntry } from 'emdash';
const { productId, showPrice = true } = Astro.props;
const product = await getEntry('products', productId);
---
<div class="product-card">
<h3>{product.data.title}</h3>
{showPrice && <p>${product.data.price}</p>}
</div>
````
```typescript
// Register with Portable Text renderer
import ProductCard from "./components/ProductCard.astro";
const components = {
types: {
productCard: ProductCard,
},
};
// Usage: <PortableText value={content} components={components} />
```
</TabItem>
</Tabs>
### Admin Pages
WordPress uses `add_menu_page()` for admin screens. EmDash plugins define admin routes.
<Tabs>
<TabItem label="WordPress">
```php
add_action('admin_menu', function() {
add_menu_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin',
'render_settings_page',
'dashicons-admin-generic',
30
);
});
function render_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
<form method="post" action="options.php">
<?php settings_fields('my_plugin_options'); ?>
<input type="text" name="api_key" value="<?php echo esc_attr(get_option('api_key')); ?>">
<?php submit_button(); ?>
</form>
</div>
<?php
}
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Plugin definition
export default {
name: 'my-plugin',
admin: {
// Menu entry
menu: {
label: 'My Plugin',
icon: 'settings'
},
// Admin page component
pages: [{
path: '/settings',
component: () => import('./admin/Settings')
}]
}
};
````
```tsx
// admin/Settings.tsx (React component)
import { useState, useEffect } from "react";
export default function Settings() {
const [apiKey, setApiKey] = useState("");
useEffect(() => {
fetch("/_emdash/api/plugins/my-plugin/settings")
.then((r) => r.json())
.then((data) => setApiKey(data.apiKey || ""));
}, []);
const save = async () => {
await fetch("/_emdash/api/plugins/my-plugin/settings", {
method: "POST",
body: JSON.stringify({ apiKey }),
});
};
return (
<div>
<h1>My Plugin Settings</h1>
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
<button onClick={save}>Save</button>
</div>
);
}
```
</TabItem>
</Tabs>
### REST API Endpoints
WordPress uses `register_rest_route()`. EmDash plugins define API handlers.
<Tabs>
<TabItem label="WordPress">
```php
add_action('rest_api_init', function() {
register_rest_route('my-plugin/v1', '/calculate', [
'methods' => 'POST',
'callback' => function($request) {
$params = $request->get_json_params();
$result = $params['a'] + $params['b'];
return new WP_REST_Response(['result' => $result]);
},
'permission_callback' => function() {
return current_user_can('edit_posts');
}
]);
});
```
</TabItem>
<TabItem label="EmDash">
```typescript
// Plugin API routes
export default {
name: 'my-plugin',
api: {
routes: [{
method: 'POST',
path: '/calculate',
handler: async (ctx, req) => {
// Check permissions
if (!ctx.user?.can('edit:content')) {
return new Response('Forbidden', { status: 403 });
}
const { a, b } = await req.json();
return Response.json({ result: a + b });
}
}]
}
};
```
</TabItem>
</Tabs>
## Migration Workflow
<Steps>
1. **Analyze the WordPress plugin**
Identify what the plugin does:
- Custom post types and fields
- Admin pages
- Shortcodes or blocks
- Hooks used
- Database tables
- API endpoints
2. **Map concepts to EmDash**
Use the tables above to find equivalents. Note which features need different approaches.
3. **Create the EmDash plugin structure**
```
my-plugin/
├── index.ts # Plugin entry point
├── hooks.ts # Hook implementations
├── api/ # API route handlers
├── admin/ # Admin UI components
└── components/ # Portable Text components
```
4. **Implement core functionality**
Start with the data model (collections and fields), then add hooks, then admin UI.
5. **Migrate data**
If the WordPress plugin stored custom data:
- Export from WordPress (custom tables, post meta)
- Transform to EmDash format
- Import via API or direct database insert
6. **Test thoroughly**
- Verify hook behavior matches expectations
- Test admin pages render correctly
- Check API endpoints return correct data
</Steps>
## Common Plugin Patterns
### SEO Plugin
WordPress SEO plugins add meta fields and generate tags.
```typescript
export default {
name: "seo",
hooks: {
"content:beforeSave": async (ctx, entry) => {
// Auto-generate meta description from excerpt
if (!entry.data.seo?.description && entry.data.excerpt) {
return {
...entry,
data: {
...entry.data,
seo: {
...entry.data.seo,
description: entry.data.excerpt.slice(0, 160),
},
},
};
}
return entry;
},
},
// Add SEO fields to all collections
fields: {
seo: {
type: "object",
fields: [
{ slug: "title", type: "string" },
{ slug: "description", type: "text" },
{ slug: "keywords", type: "string" },
],
},
},
};
```
### Form Plugin
WordPress form plugins store submissions.
```typescript
export default {
name: "forms",
// Create submissions collection on install
install: async (ctx) => {
await ctx.schema.createCollection({
slug: "form_submissions",
label: "Form Submissions",
fields: [
{ slug: "formId", type: "string" },
{ slug: "data", type: "json" },
{ slug: "submittedAt", type: "datetime" },
],
});
},
api: {
routes: [
{
method: "POST",
path: "/submit/:formId",
handler: async (ctx, req) => {
const formId = ctx.params.formId;
const data = await req.json();
await ctx.content.create("form_submissions", {
formId,
data,
submittedAt: new Date().toISOString(),
});
return Response.json({ success: true });
},
},
],
},
};
```
### E-commerce Plugin
WordPress WooCommerce patterns translated to EmDash.
```typescript
export default {
name: "shop",
collections: [
{
slug: "products",
label: "Products",
fields: [
{ slug: "title", type: "string", required: true },
{ slug: "price", type: "number", required: true },
{ slug: "salePrice", type: "number" },
{ slug: "sku", type: "string" },
{ slug: "stock", type: "number", default: 0 },
{ slug: "gallery", type: "media", multiple: true },
],
},
],
hooks: {
"content:beforeSave": async (ctx, entry) => {
if (entry.collection !== "products") return entry;
// Generate SKU if not set
if (!entry.data.sku) {
const count = await ctx.content.count("products");
entry.data.sku = `PROD-${String(count + 1).padStart(5, "0")}`;
}
return entry;
},
},
};
```
## Security Considerations
<Aside type="caution">
EmDash plugins run in a sandboxed environment with limited capabilities. Direct file system
access and shell commands are not available.
</Aside>
### Available in Sandbox
- `ctx.storage` — Database access
- `ctx.kv` — Key-value store
- `ctx.content` — Content API
- `ctx.media` — Media API
- `fetch()` — HTTP requests
### Not Available
- File system access
- Shell commands
- Environment variables (use plugin settings)
- Global state between requests
## Next Steps
- **[WordPress Migration](/migration/from-wordpress/)** — Import your WordPress content
- **[Plugin Development](/plugins/development/)** — Full plugin development guide
- **[Hooks Reference](/reference/hooks/)** — Complete hooks API

View File

@@ -57,6 +57,7 @@ hooks: {
| `timeout` | `number` | `5000` | Maximum execution time in milliseconds. | | `timeout` | `number` | `5000` | Maximum execution time in milliseconds. |
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run before this hook. | | `dependencies` | `string[]` | `[]` | Plugin IDs that must run before this hook. |
| `errorPolicy` | `"abort" \| "continue"` | `"abort"` | Whether to stop the pipeline on error. | | `errorPolicy` | `"abort" \| "continue"` | `"abort"` | Whether to stop the pipeline on error. |
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider. Used for `email:deliver` and `comment:moderate`. |
| `handler` | `function` | — | The hook handler function. Required. | | `handler` | `function` | — | The hook handler function. Required. |
<Aside type="tip"> <Aside type="tip">
@@ -494,17 +495,27 @@ Placements: `"head"`, `"body:start"`, `"body:end"`. Templates that omit a compon
## Hooks Reference ## Hooks Reference
| Hook | Trigger | Return | | Hook | Trigger | Return | Exclusive |
| ---------------------- | ------------------------- | ----------------------------- | | ----------------------- | ------------------------------ | ------------------------------ | --------- |
| `plugin:install` | First plugin installation | `void` | | `plugin:install` | First plugin installation | `void` | No |
| `plugin:activate` | Plugin enabled | `void` | | `plugin:activate` | Plugin enabled | `void` | No |
| `plugin:deactivate` | Plugin disabled | `void` | | `plugin:deactivate` | Plugin disabled | `void` | No |
| `plugin:uninstall` | Plugin removed | `void` | | `plugin:uninstall` | Plugin removed | `void` | No |
| `content:beforeSave` | Before content save | Modified content or `void` | | `content:beforeSave` | Before content save | Modified content or `void` | No |
| `content:afterSave` | After content save | `void` | | `content:afterSave` | After content save | `void` | No |
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow | | `content:beforeDelete` | Before content delete | `false` to cancel, else allow | No |
| `content:afterDelete` | After content delete | `void` | | `content:afterDelete` | After content delete | `void` | No |
| `media:beforeUpload` | Before file upload | Modified file info or `void` | | `media:beforeUpload` | Before file upload | Modified file info or `void` | No |
| `media:afterUpload` | After file upload | `void` | | `media:afterUpload` | After file upload | `void` | No |
| `page:metadata` | Page render | Contributions or `null` | | `cron` | Scheduled task fires | `void` | No |
| `page:fragments` | Page render (trusted) | Contributions or `null` | | `email:beforeSend` | Before email delivery | Modified message, `false`, or `void` | No |
| `email:deliver` | Deliver email via transport | `void` | Yes |
| `email:afterSend` | After email delivery | `void` | No |
| `comment:beforeCreate` | Before comment stored | Modified event, `false`, or `void` | No |
| `comment:moderate` | Decide comment status | `{ status, reason? }` | Yes |
| `comment:afterCreate` | After comment stored | `void` | No |
| `comment:afterModerate` | Admin changes comment status | `void` | No |
| `page:metadata` | Page render | Contributions or `null` | No |
| `page:fragments` | Page render (trusted) | Contributions or `null` | No |
See the [Hook Reference](/reference/hooks/) for complete event types and handler signatures.

View File

@@ -101,14 +101,19 @@ export default definePlugin({
Every hook and route handler receives a `PluginContext` object with access to: Every hook and route handler receives a `PluginContext` object with access to:
| Property | Description | Availability | | Property | Description | Availability |
| ------------- | -------------------------------------- | -------------------------------------- | | ------------- | ---------------------------------------------------- | -------------------------------------- |
| `ctx.storage` | Plugin's document collections | Always (if declared) | | `ctx.storage` | Plugin's document collections | Always (if declared) |
| `ctx.kv` | Key-value store for settings and state | Always | | `ctx.kv` | Key-value store for settings and state | Always |
| `ctx.content` | Read/write site content | With `read:content` or `write:content` | | `ctx.content` | Read/write site content | With `read:content` or `write:content` |
| `ctx.media` | Read/write media files | With `read:media` or `write:media` | | `ctx.media` | Read/write media files | With `read:media` or `write:media` |
| `ctx.http` | HTTP client for external requests | With `network:fetch` | | `ctx.http` | HTTP client for external requests | With `network:fetch` |
| `ctx.log` | Structured logger | Always | | `ctx.log` | Structured logger (debug, info, warn, error) | Always |
| `ctx.plugin` | Plugin metadata (id, version) | Always | | `ctx.plugin` | Plugin metadata (id, version) | Always |
| `ctx.site` | Site info: `name`, `url`, `locale` | Always |
| `ctx.url()` | Generate absolute URLs from paths | Always |
| `ctx.users` | Read user info: `get()`, `getByEmail()`, `list()` | With `read:users` |
| `ctx.cron` | Schedule tasks: `schedule()`, `cancel()`, `list()` | Always |
| `ctx.email` | Send email: `send()` | With `email:send` + provider configured |
The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability. The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability.
@@ -117,12 +122,18 @@ The context shape is identical across all hooks and routes. Capability-gated pro
Capabilities determine what APIs are available in the plugin context: Capabilities determine what APIs are available in the plugin context:
| Capability | Grants Access To | | Capability | Grants Access To |
| --------------- | ---------------------------------------------------------------------- | | ----------------- | ---------------------------------------------------------------------- |
| `read:content` | `ctx.content.get()`, `ctx.content.list()` | | `read:content` | `ctx.content.get()`, `ctx.content.list()` |
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | | `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
| `read:media` | `ctx.media.get()`, `ctx.media.list()` | | `read:media` | `ctx.media.get()`, `ctx.media.list()` |
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | | `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
| `network:fetch` | `ctx.http.fetch()` | | `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) |
| `network:fetch:any` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) |
| `read:users` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
| `email:send` | `ctx.email.send()` (requires a provider plugin) |
| `email:provide` | Register `email:deliver` exclusive hook (transport provider) |
| `email:intercept` | Register `email:beforeSend` / `email:afterSend` hooks |
| `page:inject` | Register `page:metadata` / `page:fragments` hooks |
<Aside type="tip"> <Aside type="tip">
`write:content` implies `read:content`. Same for media. Declare only what you need. `write:content` implies `read:content`. Same for media. Declare only what you need.

View File

@@ -15,7 +15,7 @@ The CLI is included with the `emdash` package:
npm install emdash npm install emdash
``` ```
Run commands with `npx emdash` or add scripts to `package.json`. The binary is also available as `ec` for brevity. Run commands with `npx emdash` or add scripts to `package.json`. The binary is also available as `em` for brevity.
## Authentication ## Authentication

View File

@@ -5,7 +5,7 @@ description: Complete reference for all EmDash field types.
import { Aside } from "@astrojs/starlight/components"; import { Aside } from "@astrojs/starlight/components";
EmDash supports 15 field types for defining content schemas. Each type maps to a SQLite column type and provides appropriate admin UI. EmDash supports 14 field types for defining content schemas. Each type maps to a SQLite column type and provides appropriate admin UI.
## Overview ## Overview

View File

@@ -5,22 +5,32 @@ description: Plugin hooks for extending EmDash functionality.
import { Aside } from "@astrojs/starlight/components"; import { Aside } from "@astrojs/starlight/components";
Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content and media lifecycle. Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
## Hook Overview ## Hook Overview
| Hook | Trigger | Can Modify | | Hook | Trigger | Can Modify | Exclusive |
| ---------------------- | ------------------------------ | ------------- | | ----------------------- | ------------------------------------- | ----------------- | --------- |
| `content:beforeSave` | Before content is saved | Content data | | `content:beforeSave` | Before content is saved | Content data | No |
| `content:afterSave` | After content is saved | Nothing | | `content:afterSave` | After content is saved | Nothing | No |
| `content:beforeDelete` | Before content is deleted | Can cancel | | `content:beforeDelete` | Before content is deleted | Can cancel | No |
| `content:afterDelete` | After content is deleted | Nothing | | `content:afterDelete` | After content is deleted | Nothing | No |
| `media:beforeUpload` | Before file is uploaded | File metadata | | `media:beforeUpload` | Before file is uploaded | File metadata | No |
| `media:afterUpload` | After file is uploaded | Nothing | | `media:afterUpload` | After file is uploaded | Nothing | No |
| `plugin:install` | When plugin is first installed | Nothing | | `cron` | Scheduled task fires | Nothing | No |
| `plugin:activate` | When plugin is enabled | Nothing | | `email:beforeSend` | Before email delivery | Message, can cancel | No |
| `plugin:deactivate` | When plugin is disabled | Nothing | | `email:deliver` | Deliver email via transport | Nothing | Yes |
| `plugin:uninstall` | When plugin is removed | Nothing | | `email:afterSend` | After successful email delivery | Nothing | No |
| `comment:beforeCreate` | Before comment is stored | Comment, can cancel | No |
| `comment:moderate` | Decide comment approval status | Status | Yes |
| `comment:afterCreate` | After comment is stored | Nothing | No |
| `comment:afterModerate` | After admin changes comment status | Nothing | No |
| `page:metadata` | Rendering public page head | Contribute tags | No |
| `page:fragments` | Rendering public page body | Inject scripts | No |
| `plugin:install` | When plugin is first installed | Nothing | No |
| `plugin:activate` | When plugin is enabled | Nothing | No |
| `plugin:deactivate` | When plugin is disabled | Nothing | No |
| `plugin:uninstall` | When plugin is removed | Nothing | No |
## Content Hooks ## Content Hooks
@@ -290,6 +300,320 @@ interface UninstallEvent {
} }
``` ```
## Cron Hook
### `cron`
Fired when a scheduled task executes. Schedule tasks with `ctx.cron.schedule()`.
```ts
hooks: {
"cron": async (event, ctx) => {
if (event.name === "daily-sync") {
const data = await ctx.http?.fetch("https://api.example.com/data");
ctx.log.info("Sync complete");
}
},
}
```
#### Event
```ts
interface CronEvent {
name: string;
data?: Record<string, unknown>;
scheduledAt: string;
}
```
## Email Hooks
Email hooks form a pipeline: `email:beforeSend` → `email:deliver` → `email:afterSend`.
### `email:beforeSend`
**Capability:** `email:intercept`
Middleware hook that runs before delivery. Transform messages or cancel delivery.
```ts
hooks: {
"email:beforeSend": async (event, ctx) => {
// Add footer to all emails
return {
...event.message,
text: event.message.text + "\n\n—Sent from My Site",
};
// Or return false to cancel delivery
},
}
```
#### Event
```ts
interface EmailBeforeSendEvent {
message: { to: string; subject: string; text: string; html?: string };
source: string;
}
```
#### Return Value
- Return modified message to transform
- Return `false` to cancel delivery
- Return `void` to pass through unchanged
### `email:deliver`
**Capability:** `email:provide` | **Exclusive:** Yes
The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.
```ts
hooks: {
"email:deliver": {
exclusive: true,
handler: async (event, ctx) => {
await sendViaSES(event.message);
},
},
}
```
### `email:afterSend`
**Capability:** `email:intercept`
Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.
```ts
hooks: {
"email:afterSend": async (event, ctx) => {
await ctx.kv.set(`email:log:${Date.now()}`, {
to: event.message.to,
subject: event.message.subject,
});
},
}
```
## Comment Hooks
Comment hooks form a pipeline: `comment:beforeCreate` → `comment:moderate` → `comment:afterCreate`. The `comment:afterModerate` hook fires separately when an admin changes a comment's status.
### `comment:beforeCreate`
**Capability:** `read:users`
Middleware hook before a comment is stored. Enrich, validate, or reject comments.
```ts
hooks: {
"comment:beforeCreate": async (event, ctx) => {
// Reject comments with links
if (event.comment.body.includes("http")) {
return false;
}
},
}
```
#### Event
```ts
interface CommentBeforeCreateEvent {
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
};
metadata: Record<string, unknown>;
}
```
#### Return Value
- Return modified event to transform
- Return `false` to reject
- Return `void` to pass through
### `comment:moderate`
**Capability:** `read:users` | **Exclusive:** Yes
Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.
```ts
hooks: {
"comment:moderate": {
exclusive: true,
handler: async (event, ctx) => {
const score = await checkSpam(event.comment);
return {
status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved",
reason: `Spam score: ${score}`,
};
},
},
}
```
#### Event
```ts
interface CommentModerateEvent {
comment: { /* same as beforeCreate */ };
metadata: Record<string, unknown>;
collectionSettings: {
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
commentsAutoApproveUsers: boolean;
};
priorApprovedCount: number;
}
```
#### Return Value
```ts
{ status: "approved" | "pending" | "spam"; reason?: string }
```
### `comment:afterCreate`
**Capability:** `read:users`
Fire-and-forget hook after a comment is stored. Use for notifications.
```ts
hooks: {
"comment:afterCreate": async (event, ctx) => {
if (event.comment.status === "approved") {
await ctx.email?.send({
to: event.contentAuthor?.email,
subject: `New comment on "${event.content.title}"`,
text: `${event.comment.authorName} commented: ${event.comment.body}`,
});
}
},
}
```
### `comment:afterModerate`
**Capability:** `read:users`
Fire-and-forget hook when an admin manually changes a comment's status.
#### Event
```ts
interface CommentAfterModerateEvent {
comment: { id: string; /* ... */ };
previousStatus: string;
newStatus: string;
moderator: { id: string; name: string | null };
}
```
## Page Hooks
Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
### `page:metadata`
**Capability:** `page:inject`
Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.
```ts
hooks: {
"page:metadata": async (event, ctx) => {
return [
{ kind: "meta", name: "generator", content: "EmDash" },
{ kind: "property", property: "og:site_name", content: event.page.siteName },
{ kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } },
];
},
}
```
#### Contribution Types
```ts
type PageMetadataContribution =
| { kind: "meta"; name: string; content: string; key?: string }
| { kind: "property"; property: string; content: string; key?: string }
| { kind: "link"; rel: string; href: string; hreflang?: string; key?: string }
| { kind: "jsonld"; id?: string; graph: Record<string, unknown> };
```
The `key` field deduplicates contributions — only the last contribution with a given key is used.
### `page:fragments`
**Capability:** `page:inject`
Inject scripts or HTML into pages. Only available to trusted (native) plugins.
```ts
hooks: {
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "body:end",
src: "https://analytics.example.com/script.js",
async: true,
},
{
kind: "inline-script",
placement: "head",
code: `window.siteId = "abc123";`,
},
];
},
}
```
#### Contribution Types
```ts
type PageFragmentContribution =
| {
kind: "external-script";
placement: "head" | "body:start" | "body:end";
src: string;
async?: boolean;
defer?: boolean;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "inline-script";
placement: "head" | "body:start" | "body:end";
code: string;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "html";
placement: "head" | "body:start" | "body:end";
html: string;
key?: string;
};
```
## Hook Configuration ## Hook Configuration
Hooks accept either a handler function or a configuration object: Hooks accept either a handler function or a configuration object:
@@ -313,11 +637,12 @@ hooks: {
### Configuration Options ### Configuration Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
| -------------- | ---------- | --------- | ---------------------------------- | | -------------- | ---------- | --------- | ---------------------------------------------------- |
| `priority` | `number` | `100` | Execution order (lower = earlier) | | `priority` | `number` | `100` | Execution order (lower = earlier) |
| `timeout` | `number` | `5000` | Max execution time in milliseconds | | `timeout` | `number` | `5000` | Max execution time in milliseconds |
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run first | | `dependencies` | `string[]` | `[]` | Plugin IDs that must run first |
| `errorPolicy` | `string` | `"abort"` | `"continue"` to ignore errors | | `errorPolicy` | `string` | `"abort"` | `"continue"` to ignore errors |
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider (for provider-pattern hooks like `email:deliver`, `comment:moderate`) |
## Plugin Context ## Plugin Context
@@ -326,15 +651,22 @@ All hooks receive a context object with access to plugin APIs:
```ts ```ts
interface PluginContext { interface PluginContext {
plugin: { id: string; version: string }; plugin: { id: string; version: string };
storage: PluginStorage; // Declared storage collections storage: PluginStorage;
kv: KVAccess; // Key-value store kv: KVAccess;
content?: ContentAccess; // If read:content or write:content capability content?: ContentAccess;
media?: MediaAccess; // If read:media or write:media capability media?: MediaAccess;
http?: HttpAccess; // If network:fetch capability http?: HttpAccess;
log: LogAccess; // Always available log: LogAccess;
site: { name: string; url: string; locale: string };
url(path: string): string;
users?: UserAccess;
cron?: CronAccess;
email?: EmailAccess;
} }
``` ```
See [Plugin Overview — Plugin Context](/plugins/overview/#plugin-context) for capability requirements and method details.
<Aside> <Aside>
Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin
definition. definition.

View File

@@ -65,7 +65,7 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma
## Tools ## Tools
The server exposes 28 tools across seven domains. Each tool returns results as JSON text content, or an error message with `isError: true` on failure. The server exposes 33 tools across seven domains. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.
### Content Tools ### Content Tools

View File

@@ -139,7 +139,7 @@ Content-Type: application/json
### Update Content ### Update Content
```http ```http
PATCH /_emdash/api/content/:collection/:id PUT /_emdash/api/content/:collection/:id
Content-Type: application/json Content-Type: application/json
``` ```
@@ -244,7 +244,7 @@ Content-Type: application/json
### Update Media ### Update Media
```http ```http
PATCH /_emdash/api/media/:id PUT /_emdash/api/media/:id
Content-Type: application/json Content-Type: application/json
``` ```
@@ -626,7 +626,7 @@ GET /_emdash/api/settings
### Update Settings ### Update Settings
```http ```http
PUT /_emdash/api/settings POST /_emdash/api/settings
Content-Type: application/json Content-Type: application/json
{ {

View File

@@ -11,7 +11,7 @@ An EmDash theme is a complete Astro site -- pages, layouts, components, styles -
- **A theme is a working Astro project.** There's no theme API or abstraction layer. You build a site and ship it as a template. The seed file just tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run. - **A theme is a working Astro project.** There's no theme API or abstraction layer. You build a site and ship it as a template. The seed file just tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run.
- **EmDash gives you more control over the content model than WordPress.** Themes take advantage of this -- the seed file declares exactly what fields each collection needs. Build on the standard **posts** and **pages** collections and add fields and taxonomies as your design requires, rather than inventing entirely new content types. - **EmDash gives you more control over the content model than WordPress.** Themes take advantage of this -- the seed file declares exactly what fields each collection needs. Build on the standard **posts** and **pages** collections and add fields and taxonomies as your design requires, rather than inventing entirely new content types.
- **Content pages must be server-rendered.** Content changes at runtime through the admin UI, so pages that display EmDash content cannot be prerendered. Never use `getStaticPaths()` for EmDash content routes. - **Theme content pages must be server-rendered.** In a theme, content changes at runtime through the admin UI, so pages that display EmDash content must not be prerendered. Do not use `getStaticPaths()` in theme content routes. (Static site builds using EmDash as a build-time data source _can_ use `getStaticPaths`, but themes are always SSR.)
- **No hard-coded content.** Site title, tagline, navigation, and other dynamic content come from the CMS via API calls -- not from template strings. - **No hard-coded content.** Site title, tagline, navigation, and other dynamic content come from the CMS via API calls -- not from template strings.
## Project Structure ## Project Structure
@@ -160,8 +160,9 @@ See [Seed File Format](/themes/seed-files/) for the complete specification, incl
All pages that display EmDash content are server-rendered. Use `Astro.params` to get the slug from the URL and query content at request time. All pages that display EmDash content are server-rendered. Use `Astro.params` to get the slug from the URL and query content at request time.
<Aside type="caution"> <Aside type="caution">
Never use `getStaticPaths()` or `export const prerender = true` for pages that display EmDash In themes, never use `getStaticPaths()` or `export const prerender = true` for pages that display
content. Content changes at runtime through the admin UI, so these pages must be server-rendered. EmDash content. Themes serve content at runtime through the admin UI, so these pages must be
server-rendered.
</Aside> </Aside>
### Homepage ### Homepage

View File

@@ -184,7 +184,7 @@ orderBy: { publishedAt: "desc" },
```astro title="src/pages/posts/[slug].astro" ```astro title="src/pages/posts/[slug].astro"
--- ---
import { getEmDashCollection, getEntryTerms } from "emdash"; import { getEmDashCollection, getEntryTerms } from "emdash";
import { PortableText } from "emdash/astro"; import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro"; import Base from "../../layouts/Base.astro";
export async function getStaticPaths() { export async function getStaticPaths() {
@@ -261,7 +261,7 @@ Identify widget areas in the theme and render them:
```astro title="src/components/Sidebar.astro" ```astro title="src/components/Sidebar.astro"
--- ---
import { getWidgetArea, getMenu } from "emdash"; import { getWidgetArea, getMenu } from "emdash";
import { PortableText } from "emdash/astro"; import { PortableText } from "emdash/ui";
import RecentPosts from "./widgets/RecentPosts.astro"; import RecentPosts from "./widgets/RecentPosts.astro";
const sidebar = await getWidgetArea("sidebar"); const sidebar = await getWidgetArea("sidebar");