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:
@@ -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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
},
|
}),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user