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
|
||||
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.
|
||||
Reduces D1 row reads by 90%+ for dashboard operations.
|
||||
|
||||
@@ -19,11 +19,6 @@ export default defineConfig({
|
||||
label: "GitHub",
|
||||
href: "https://github.com/emdash-cms/emdash",
|
||||
},
|
||||
{
|
||||
icon: "discord",
|
||||
label: "Discord",
|
||||
href: "https://astro.build/chat",
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: "https://github.com/emdash-cms/emdash/tree/main/docs",
|
||||
|
||||
@@ -69,7 +69,7 @@ CREATE TABLE ec_posts (
|
||||
-- System columns (always present)
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE,
|
||||
status TEXT DEFAULT 'draft', -- draft, published, archived
|
||||
status TEXT DEFAULT 'draft', -- draft, published, scheduled
|
||||
author_id TEXT,
|
||||
created_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:
|
||||
|
||||
- **Content hooks** — `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`
|
||||
- **Media hooks** — `beforeMediaUpload`, `afterMediaUpload`
|
||||
- **Content hooks** — `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, `content:afterDelete`
|
||||
- **Media hooks** — `media:beforeUpload`, `media:afterUpload`
|
||||
- **Isolated storage** — Each plugin gets namespaced KV access
|
||||
- **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") |
|
||||
| `description` | Optional description for editors |
|
||||
| `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">
|
||||
Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`,
|
||||
@@ -223,7 +223,8 @@ Every field supports these properties:
|
||||
|
||||
<Aside type="caution">
|
||||
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>
|
||||
|
||||
## Validation Rules
|
||||
@@ -376,6 +377,6 @@ Field types map to SQLite column types:
|
||||
Organize content with [categories and tags](/guides/taxonomies/).
|
||||
</Card>
|
||||
<Card title="Media Library" icon="seti:image">
|
||||
Manage [images and files](/guides/media/).
|
||||
Manage [images and files](/guides/media-library/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
@@ -230,12 +230,14 @@ export interface Product {
|
||||
|
||||
// Typed overloads for query functions
|
||||
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(
|
||||
type: "products",
|
||||
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/).
|
||||
</Card>
|
||||
<Card title="Seeding" icon="open-book">
|
||||
Set up sites with [seed files](/guides/seeding/).
|
||||
Set up sites with [seed files](/themes/seed-files/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
@@ -61,8 +61,8 @@ packages/core/src/
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** 20 or higher
|
||||
- **pnpm** 9 or higher
|
||||
- **Node.js** 22 or higher
|
||||
- **pnpm** 10 or higher
|
||||
- **Git**
|
||||
|
||||
```bash
|
||||
@@ -151,7 +151,7 @@ pnpm --filter emdash test --watch
|
||||
</TabItem>
|
||||
<TabItem label="E2E tests">
|
||||
```bash
|
||||
pnpm --filter emdash-demo test:e2e
|
||||
pnpm test:e2e
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
@@ -182,7 +182,7 @@ pnpm lint:json
|
||||
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
|
||||
|
||||
|
||||
@@ -64,8 +64,8 @@ Update your Astro configuration to use D1 and R2:
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import emdash, { r2 } from "emdash/astro";
|
||||
import { d1 } from "emdash/db";
|
||||
import emdash from "emdash/astro";
|
||||
import { d1, r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
@@ -170,16 +170,14 @@ If your organization uses Cloudflare Access, you can use it as your authenticati
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
auth: {
|
||||
cloudflareAccess: {
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "your-app-audience-tag",
|
||||
roleMapping: {
|
||||
"Admins": 50,
|
||||
"Editors": 40,
|
||||
},
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "your-app-audience-tag",
|
||||
roleMapping: {
|
||||
"Admins": 50,
|
||||
"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.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { d1 } from "emdash/db";
|
||||
import { d1 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
@@ -301,7 +301,8 @@ wrangler d1 migrations apply DB
|
||||
Use different databases per environment:
|
||||
|
||||
```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" });
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
```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({
|
||||
integrations: [
|
||||
@@ -188,7 +189,8 @@ The `baseUrl` should match EmDash's media file endpoint (`/_emdash/api/media/fil
|
||||
Switch storage backends based on environment:
|
||||
|
||||
```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
|
||||
? 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.
|
||||
|
||||
## 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
|
||||
|
||||
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 cloudflare from "@astrojs/cloudflare";
|
||||
import emdash from "emdash/astro";
|
||||
import { d1 } from "emdash/db";
|
||||
import { d1, access } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
@@ -249,12 +249,10 @@ export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
auth: {
|
||||
cloudflareAccess: {
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "abc123def456...", // From Access app settings
|
||||
},
|
||||
},
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
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) |
|
||||
| `syncRoles` | `boolean` | `false` | Update role on each login based on IdP groups |
|
||||
| `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
|
||||
|
||||
@@ -277,18 +276,16 @@ Map your IdP groups to EmDash roles:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
auth: {
|
||||
cloudflareAccess: {
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "abc123...",
|
||||
roleMapping: {
|
||||
Admins: 50, // Admin
|
||||
"Content Editors": 40, // Editor
|
||||
Writers: 30, // Author
|
||||
},
|
||||
defaultRole: 20, // Contributor for users not in any group
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "abc123...",
|
||||
roleMapping: {
|
||||
Admins: 50, // Admin
|
||||
"Content Editors": 40, // Editor
|
||||
Writers: 30, // Author
|
||||
},
|
||||
},
|
||||
defaultRole: 20, // Contributor for users not in any group
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ The default posts collection includes:
|
||||
- `content` - Rich text body
|
||||
- `excerpt` - Short description
|
||||
- `featured_image` - Header image (optional)
|
||||
- `status` - draft, published, or archived
|
||||
- `status` - draft, published, or scheduled
|
||||
- `publishedAt` - Publication date (system field)
|
||||
|
||||
## Create Your First Post
|
||||
@@ -96,6 +96,7 @@ Create a dynamic route for individual posts:
|
||||
```astro title="src/pages/blog/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -125,7 +126,7 @@ if (!post) {
|
||||
<time datetime={post.data.publishedAt?.toISOString()}>
|
||||
{post.data.publishedAt?.toLocaleDateString()}
|
||||
</time>
|
||||
<div set:html={post.data.content} />
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
@@ -285,43 +285,34 @@ Access media programmatically using the admin API.
|
||||
|
||||
### Upload a File
|
||||
|
||||
Request a signed upload URL:
|
||||
Upload media as multipart form data:
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/media/upload
|
||||
Content-Type: application/json
|
||||
POST /_emdash/api/media
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"filename": "hero-image.jpg",
|
||||
"contentType": "image/jpeg",
|
||||
"size": 245000
|
||||
}
|
||||
file=<binary file data>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"url": "https://storage.example.com/signed-upload-url...",
|
||||
"method": "PUT",
|
||||
"headers": {
|
||||
"Content-Type": "image/jpeg"
|
||||
},
|
||||
"expiresAt": "2024-01-15T12:00:00Z",
|
||||
"key": "media/abc123/hero-image.jpg"
|
||||
"success": true,
|
||||
"data": {
|
||||
"item": {
|
||||
"id": "01ABC123",
|
||||
"filename": "hero-image.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"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
|
||||
|
||||
```bash
|
||||
@@ -444,19 +435,19 @@ The local media library ("Library" tab) is always available alongside any config
|
||||
|
||||
### 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"
|
||||
---
|
||||
import { EmDashMedia } from "emdash/ui";
|
||||
import { Image } from "emdash/ui";
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
---
|
||||
|
||||
{post?.data.featured_image && (
|
||||
<EmDashMedia
|
||||
value={post.data.featured_image}
|
||||
<Image
|
||||
image={post.data.featured_image}
|
||||
width={800}
|
||||
height={450}
|
||||
/>
|
||||
@@ -465,7 +456,7 @@ const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
|
||||
The component automatically:
|
||||
- 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)
|
||||
|
||||
### MediaValue Type
|
||||
@@ -474,9 +465,10 @@ Media fields store a `MediaValue` object containing provider information:
|
||||
|
||||
```ts
|
||||
interface MediaValue {
|
||||
provider: string; // Provider ID (e.g., "local", "cloudflare-images")
|
||||
provider?: string; // Provider ID, defaults to "local"
|
||||
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
|
||||
mimeType?: string; // MIME type
|
||||
width?: number; // Image/video width
|
||||
|
||||
@@ -70,7 +70,7 @@ import { getPreviewUrl } from "emdash";
|
||||
const previewUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: process.env.PREVIEW_SECRET!,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...
|
||||
@@ -82,7 +82,7 @@ With a base URL for absolute links:
|
||||
const fullUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: process.env.PREVIEW_SECRET!,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...
|
||||
@@ -94,7 +94,7 @@ With a custom path pattern:
|
||||
const blogUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: process.env.PREVIEW_SECRET!,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
pathPattern: "/blog/{id}",
|
||||
});
|
||||
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...
|
||||
@@ -133,13 +133,13 @@ import { verifyPreviewToken } from "emdash";
|
||||
// From a URL (extracts _preview query parameter)
|
||||
const result = await verifyPreviewToken({
|
||||
url: Astro.url,
|
||||
secret: import.meta.env.PREVIEW_SECRET,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
});
|
||||
|
||||
// Or with a token directly
|
||||
const result = await verifyPreviewToken({
|
||||
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.
|
||||
|
||||
<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.
|
||||
</Aside>
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ When `locale` is omitted, it defaults to the request's current locale. If no tra
|
||||
|
||||
### Filter by Status
|
||||
|
||||
Retrieve only published, draft, or archived content:
|
||||
Retrieve only published or draft content:
|
||||
|
||||
```ts
|
||||
// Only published posts
|
||||
@@ -90,16 +90,11 @@ const { entries: published } = await getEmDashCollection("posts", {
|
||||
const { entries: drafts } = await getEmDashCollection("posts", {
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
// Only archived
|
||||
const { entries: archived } = await getEmDashCollection("posts", {
|
||||
status: "archived",
|
||||
});
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Always filter by `status: "published"` for public-facing pages. Draft and archived content should
|
||||
only be accessible in the admin or preview mode.
|
||||
Always filter by `status: "published"` for public-facing pages. Draft content should only be
|
||||
accessible in the admin or preview mode.
|
||||
</Aside>
|
||||
|
||||
### Limit Results
|
||||
@@ -161,6 +156,7 @@ Use `getEmDashEntry` to retrieve one entry by its ID or slug:
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post, error } = await getEmDashEntry("posts", slug);
|
||||
@@ -176,7 +172,7 @@ if (!post) {
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<div set:html={post.data.content} />
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
```
|
||||
|
||||
|
||||
@@ -32,15 +32,17 @@ Fetch multiple sections with optional filters:
|
||||
import { getSections } from "emdash";
|
||||
|
||||
// Get all sections
|
||||
const all = await getSections();
|
||||
const { items: all } = await getSections();
|
||||
|
||||
// Filter by source
|
||||
const themeSections = await getSections({ source: "theme" });
|
||||
const { items: themeSections } = await getSections({ source: "theme" });
|
||||
|
||||
// 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
|
||||
|
||||
```typescript
|
||||
@@ -51,7 +53,7 @@ interface Section {
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
content: PortableTextBlock[];
|
||||
previewMedia?: { id: string; url: string };
|
||||
previewUrl?: string;
|
||||
source: "theme" | "user" | "import";
|
||||
themeId?: string;
|
||||
createdAt: string;
|
||||
|
||||
@@ -202,8 +202,8 @@ const { entries: posts } = await getEmDashCollection("posts", {
|
||||
<ul class="recent-posts">
|
||||
{posts.map(post => (
|
||||
<li>
|
||||
{showThumbnails && post.data.featuredImage && (
|
||||
<img src={post.data.featuredImage} alt="" class="thumbnail" />
|
||||
{showThumbnails && post.data.featured_image && (
|
||||
<img src={post.data.featured_image} alt="" class="thumbnail" />
|
||||
)}
|
||||
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
|
||||
{showDate && post.data.publishedAt && (
|
||||
|
||||
@@ -217,22 +217,11 @@ Use this table when adapting WordPress patterns to EmDash:
|
||||
| `$wpdb` | `ctx.storage` | Direct storage access |
|
||||
| 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
|
||||
# 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.
|
||||
The import API endpoints are under `/_emdash/api/import/wordpress/` for programmatic access.
|
||||
|
||||
## 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. |
|
||||
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run before this hook. |
|
||||
| `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. |
|
||||
|
||||
<Aside type="tip">
|
||||
@@ -494,17 +495,27 @@ Placements: `"head"`, `"body:start"`, `"body:end"`. Templates that omit a compon
|
||||
|
||||
## Hooks Reference
|
||||
|
||||
| Hook | Trigger | Return |
|
||||
| ---------------------- | ------------------------- | ----------------------------- |
|
||||
| `plugin:install` | First plugin installation | `void` |
|
||||
| `plugin:activate` | Plugin enabled | `void` |
|
||||
| `plugin:deactivate` | Plugin disabled | `void` |
|
||||
| `plugin:uninstall` | Plugin removed | `void` |
|
||||
| `content:beforeSave` | Before content save | Modified content or `void` |
|
||||
| `content:afterSave` | After content save | `void` |
|
||||
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow |
|
||||
| `content:afterDelete` | After content delete | `void` |
|
||||
| `media:beforeUpload` | Before file upload | Modified file info or `void` |
|
||||
| `media:afterUpload` | After file upload | `void` |
|
||||
| `page:metadata` | Page render | Contributions or `null` |
|
||||
| `page:fragments` | Page render (trusted) | Contributions or `null` |
|
||||
| Hook | Trigger | Return | Exclusive |
|
||||
| ----------------------- | ------------------------------ | ------------------------------ | --------- |
|
||||
| `plugin:install` | First plugin installation | `void` | No |
|
||||
| `plugin:activate` | Plugin enabled | `void` | No |
|
||||
| `plugin:deactivate` | Plugin disabled | `void` | No |
|
||||
| `plugin:uninstall` | Plugin removed | `void` | No |
|
||||
| `content:beforeSave` | Before content save | Modified content or `void` | No |
|
||||
| `content:afterSave` | After content save | `void` | No |
|
||||
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow | No |
|
||||
| `content:afterDelete` | After content delete | `void` | No |
|
||||
| `media:beforeUpload` | Before file upload | Modified file info or `void` | No |
|
||||
| `media:afterUpload` | After file upload | `void` | No |
|
||||
| `cron` | Scheduled task fires | `void` | No |
|
||||
| `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.
|
||||
|
||||
@@ -100,15 +100,20 @@ export default definePlugin({
|
||||
|
||||
Every hook and route handler receives a `PluginContext` object with access to:
|
||||
|
||||
| Property | Description | Availability |
|
||||
| ------------- | -------------------------------------- | -------------------------------------- |
|
||||
| `ctx.storage` | Plugin's document collections | Always (if declared) |
|
||||
| `ctx.kv` | Key-value store for settings and state | Always |
|
||||
| `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.http` | HTTP client for external requests | With `network:fetch` |
|
||||
| `ctx.log` | Structured logger | Always |
|
||||
| `ctx.plugin` | Plugin metadata (id, version) | Always |
|
||||
| Property | Description | Availability |
|
||||
| ------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| `ctx.storage` | Plugin's document collections | Always (if declared) |
|
||||
| `ctx.kv` | Key-value store for settings and state | Always |
|
||||
| `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.http` | HTTP client for external requests | With `network:fetch` |
|
||||
| `ctx.log` | Structured logger (debug, info, warn, error) | 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.
|
||||
|
||||
@@ -116,13 +121,19 @@ The context shape is identical across all hooks and routes. Capability-gated pro
|
||||
|
||||
Capabilities determine what APIs are available in the plugin context:
|
||||
|
||||
| Capability | Grants Access To |
|
||||
| --------------- | ---------------------------------------------------------------------- |
|
||||
| `read:content` | `ctx.content.get()`, `ctx.content.list()` |
|
||||
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
|
||||
| `read:media` | `ctx.media.get()`, `ctx.media.list()` |
|
||||
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` |
|
||||
| `network:fetch` | `ctx.http.fetch()` |
|
||||
| Capability | Grants Access To |
|
||||
| ----------------- | ---------------------------------------------------------------------- |
|
||||
| `read:content` | `ctx.content.get()`, `ctx.content.list()` |
|
||||
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
|
||||
| `read:media` | `ctx.media.get()`, `ctx.media.list()` |
|
||||
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
|
||||
| `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">
|
||||
`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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: Complete reference for all EmDash field types.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -5,22 +5,32 @@ description: Plugin hooks for extending EmDash functionality.
|
||||
|
||||
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 | Trigger | Can Modify |
|
||||
| ---------------------- | ------------------------------ | ------------- |
|
||||
| `content:beforeSave` | Before content is saved | Content data |
|
||||
| `content:afterSave` | After content is saved | Nothing |
|
||||
| `content:beforeDelete` | Before content is deleted | Can cancel |
|
||||
| `content:afterDelete` | After content is deleted | Nothing |
|
||||
| `media:beforeUpload` | Before file is uploaded | File metadata |
|
||||
| `media:afterUpload` | After file is uploaded | Nothing |
|
||||
| `plugin:install` | When plugin is first installed | Nothing |
|
||||
| `plugin:activate` | When plugin is enabled | Nothing |
|
||||
| `plugin:deactivate` | When plugin is disabled | Nothing |
|
||||
| `plugin:uninstall` | When plugin is removed | Nothing |
|
||||
| Hook | Trigger | Can Modify | Exclusive |
|
||||
| ----------------------- | ------------------------------------- | ----------------- | --------- |
|
||||
| `content:beforeSave` | Before content is saved | Content data | No |
|
||||
| `content:afterSave` | After content is saved | Nothing | No |
|
||||
| `content:beforeDelete` | Before content is deleted | Can cancel | No |
|
||||
| `content:afterDelete` | After content is deleted | Nothing | No |
|
||||
| `media:beforeUpload` | Before file is uploaded | File metadata | No |
|
||||
| `media:afterUpload` | After file is uploaded | Nothing | No |
|
||||
| `cron` | Scheduled task fires | Nothing | No |
|
||||
| `email:beforeSend` | Before email delivery | Message, can cancel | No |
|
||||
| `email:deliver` | Deliver email via transport | Nothing | Yes |
|
||||
| `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
|
||||
|
||||
@@ -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
|
||||
|
||||
Hooks accept either a handler function or a configuration object:
|
||||
@@ -312,12 +636,13 @@ hooks: {
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | ---------- | --------- | ---------------------------------- |
|
||||
| `priority` | `number` | `100` | Execution order (lower = earlier) |
|
||||
| `timeout` | `number` | `5000` | Max execution time in milliseconds |
|
||||
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run first |
|
||||
| `errorPolicy` | `string` | `"abort"` | `"continue"` to ignore errors |
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | ---------- | --------- | ---------------------------------------------------- |
|
||||
| `priority` | `number` | `100` | Execution order (lower = earlier) |
|
||||
| `timeout` | `number` | `5000` | Max execution time in milliseconds |
|
||||
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run first |
|
||||
| `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
|
||||
|
||||
@@ -326,15 +651,22 @@ All hooks receive a context object with access to plugin APIs:
|
||||
```ts
|
||||
interface PluginContext {
|
||||
plugin: { id: string; version: string };
|
||||
storage: PluginStorage; // Declared storage collections
|
||||
kv: KVAccess; // Key-value store
|
||||
content?: ContentAccess; // If read:content or write:content capability
|
||||
media?: MediaAccess; // If read:media or write:media capability
|
||||
http?: HttpAccess; // If network:fetch capability
|
||||
log: LogAccess; // Always available
|
||||
storage: PluginStorage;
|
||||
kv: KVAccess;
|
||||
content?: ContentAccess;
|
||||
media?: MediaAccess;
|
||||
http?: HttpAccess;
|
||||
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>
|
||||
Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin
|
||||
definition.
|
||||
|
||||
@@ -65,7 +65,7 @@ Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) forma
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ Content-Type: application/json
|
||||
### Update Content
|
||||
|
||||
```http
|
||||
PATCH /_emdash/api/content/:collection/:id
|
||||
PUT /_emdash/api/content/:collection/:id
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
@@ -244,7 +244,7 @@ Content-Type: application/json
|
||||
### Update Media
|
||||
|
||||
```http
|
||||
PATCH /_emdash/api/media/:id
|
||||
PUT /_emdash/api/media/:id
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
@@ -626,7 +626,7 @@ GET /_emdash/api/settings
|
||||
### Update Settings
|
||||
|
||||
```http
|
||||
PUT /_emdash/api/settings
|
||||
POST /_emdash/api/settings
|
||||
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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
||||
<Aside type="caution">
|
||||
Never use `getStaticPaths()` or `export const prerender = true` for pages that display EmDash
|
||||
content. Content changes at runtime through the admin UI, so these pages must be server-rendered.
|
||||
In themes, never use `getStaticPaths()` or `export const prerender = true` for pages that display
|
||||
EmDash content. Themes serve content at runtime through the admin UI, so these pages must be
|
||||
server-rendered.
|
||||
</Aside>
|
||||
|
||||
### Homepage
|
||||
|
||||
@@ -184,7 +184,7 @@ orderBy: { publishedAt: "desc" },
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import { PortableText } from "emdash/astro";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
@@ -261,7 +261,7 @@ Identify widget areas in the theme and render them:
|
||||
```astro title="src/components/Sidebar.astro"
|
||||
---
|
||||
import { getWidgetArea, getMenu } from "emdash";
|
||||
import { PortableText } from "emdash/astro";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import RecentPosts from "./widgets/RecentPosts.astro";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
|
||||
Reference in New Issue
Block a user