first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
# Admin UI
Plugins extend the admin panel with React pages and dashboard widgets.
## Entry Point
Export pages and widgets from `src/admin.tsx`:
```typescript
// src/admin.tsx
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
// Pages keyed by path (must match admin.pages paths)
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
// Widgets keyed by ID (must match admin.widgets IDs)
export const widgets = {
status: StatusWidget,
};
```
Reference in plugin definition:
```typescript
definePlugin({
id: "my-plugin",
version: "1.0.0",
admin: {
entry: "@my-org/my-plugin/admin",
pages: [
{ path: "/settings", label: "Settings", icon: "settings" },
{ path: "/reports", label: "Reports", icon: "chart" },
],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
});
```
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`.
## Pages
React components. Use `usePluginAPI()` to call plugin routes.
```typescript
// src/components/SettingsPage.tsx
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdashcms/admin";
export function SettingsPage() {
const api = usePluginAPI();
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get("settings").then(setSettings);
}, []);
const handleSave = async () => {
setSaving(true);
await api.post("settings/save", settings);
setSaving(false);
};
return (
<div>
<h1>Settings</h1>
<label>
Site Title
<input
type="text"
value={settings.siteTitle || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save"}
</button>
</div>
);
}
```
## Widgets
Dashboard cards with at-a-glance info.
```typescript
// src/components/StatusWidget.tsx
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdashcms/admin";
export function StatusWidget() {
const api = usePluginAPI();
const [data, setData] = useState({ count: 0 });
useEffect(() => {
api.get("status").then(setData);
}, []);
return (
<div className="widget-content">
<div className="score">{data.count}</div>
</div>
);
}
```
### Widget Sizes
| Size | Width |
| ------- | -------------------- |
| `full` | Full dashboard width |
| `half` | Half width |
| `third` | One-third width |
## usePluginAPI()
Auto-prefixes plugin ID to route URLs:
```typescript
const api = usePluginAPI();
const data = await api.get("status"); // GET /.../plugins/<id>/status
await api.post("settings/save", { enabled: true }); // POST with body
const result = await api.get("history?limit=50"); // Query params
```
## Admin Components
Pre-built components from `@emdashcms/admin`:
```typescript
import { Card, Button, Input, Select, Toggle, Table, Loading, Alert } from "@emdashcms/admin";
```
## Auto-Generated Settings
If your plugin only needs settings, skip custom pages — use `settingsSchema` and EmDash generates the form:
```typescript
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true },
}
}
```
## Build Configuration
Admin components need a separate build entry:
```typescript
// tsdown.config.ts
export default {
entry: {
index: "src/index.ts",
admin: "src/admin.tsx",
},
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdashcms/admin"],
};
```
Keep React and `@emdashcms/admin` as externals to avoid bundling duplicates.
## Plugin Descriptor
The descriptor (returned by factory function) also declares admin metadata:
```typescript
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
version: "1.0.0",
options,
adminEntry: "@my-org/my-plugin/admin",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}
```

View File

@@ -0,0 +1,265 @@
# API Routes
Plugin routes work in both standard and native plugins, and in both trusted and sandboxed modes. Sandboxed plugin routes are invoked via the sandbox runner's `invokeRoute()` RPC.
Plugin routes expose REST endpoints at `/_emdash/api/plugins/<plugin-id>/<route-name>`.
## Defining Routes
```typescript
import { definePlugin } from "emdash";
import { z } from "astro/zod";
definePlugin({
id: "forms",
version: "1.0.0",
routes: {
// Simple route
status: {
handler: async (ctx) => {
return { ok: true };
},
},
// Route with input validation
submissions: {
input: z.object({
formId: z.string().optional(),
limit: z.number().default(50),
cursor: z.string().optional(),
}),
handler: async (ctx) => {
const { formId, limit, cursor } = ctx.input;
const result = await ctx.storage.submissions!.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items,
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
// Nested path
"settings/save": {
input: z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
}),
handler: async (ctx) => {
for (const [key, value] of Object.entries(ctx.input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
},
},
},
});
```
## Route URLs
| Plugin ID | Route Name | URL |
| --------- | --------------- | ------------------------------------------ |
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
## Handler Context
```typescript
interface RouteContext<TInput = unknown> extends PluginContext {
input: TInput; // Validated input
request: Request; // Original request
plugin: { id: string; version: string };
storage: Record<string, StorageCollection>;
kv: KVAccess;
content?: ContentAccess; // If capability declared
media?: MediaAccess;
http?: HttpAccess;
log: LogAccess;
}
```
## Input Validation
Use Zod schemas. Invalid input returns 400.
```typescript
routes: {
create: {
input: z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).optional(),
}),
handler: async (ctx) => {
// ctx.input is typed and validated
const { title, email, priority } = ctx.input;
// ...
},
},
}
```
Input sources:
- **POST/PUT/PATCH** — Request body (JSON)
- **GET/DELETE** — URL query parameters
## Return Values
Return any JSON-serializable value. Response is always `Content-Type: application/json`.
```typescript
return { success: true, data: items }; // Object
return items; // Array
return 42; // Primitive
```
## Errors
Throw to return error response:
```typescript
throw new Error("Item not found"); // 500 with { error: "Item not found" }
// Custom status code
throw new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
```
## HTTP Methods
Routes respond to all methods. Check `ctx.request.method`:
```typescript
handler: async (ctx) => {
switch (ctx.request.method) {
case "GET":
return await ctx.storage.items!.get(ctx.input.id);
case "DELETE":
await ctx.storage.items!.delete(ctx.input.id);
return { deleted: true };
default:
throw new Response("Method not allowed", { status: 405 });
}
};
```
## Common Patterns
### Settings CRUD
```typescript
routes: {
settings: {
handler: async (ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
result[entry.key.replace("settings:", "")] = entry.value;
}
return result;
},
},
"settings/save": {
handler: async (ctx) => {
const input = await ctx.request.json();
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) await ctx.kv.set(`settings:${key}`, value);
}
return { success: true };
},
},
}
```
### Paginated List
```typescript
routes: {
list: {
input: z.object({
limit: z.number().min(1).max(100).default(50),
cursor: z.string().optional(),
status: z.string().optional(),
}),
handler: async (ctx) => {
const { limit, cursor, status } = ctx.input;
const result = await ctx.storage.items!.query({
where: status ? { status } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return {
items: result.items.map((item) => ({ id: item.id, ...item.data })),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
}
```
### External API Proxy
Requires `network:fetch` capability and `allowedHosts`:
```typescript
definePlugin({
capabilities: ["network:fetch"],
allowedHosts: ["api.weather.example.com"],
routes: {
forecast: {
input: z.object({ city: z.string() }),
handler: async (ctx) => {
const apiKey = await ctx.kv.get<string>("settings:apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http!.fetch(
`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) throw new Error(`API error: ${response.status}`);
return response.json();
},
},
},
});
```
## Calling from Admin UI
```typescript
import { usePluginAPI } from "@emdashcms/admin";
const api = usePluginAPI();
const data = await api.get("status");
await api.post("settings/save", { enabled: true });
```
## Calling Externally
```bash
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Content-Type: application/json" \
-d '{"title": "Hello"}'
```
Plugin routes don't have built-in auth. Admin-only routes are protected by the admin session middleware.

View File

@@ -0,0 +1,415 @@
# Block Kit
Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.
Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.
Block Kit elements are also used for [Portable Text block editing fields](./portable-text-blocks.md). When a plugin declares `fields` on a block type, the editor renders a Block Kit form.
## How It Works
1. User navigates to plugin admin page
2. Admin sends `page_load` interaction to plugin's admin route
3. Plugin returns `BlockResponse` with array of blocks
4. Admin renders blocks using `BlockRenderer`
5. User interacts (button click, form submit) → interaction sent back
6. Plugin returns new blocks
```typescript
routes: {
admin: {
handler: async (ctx) => {
const interaction = await ctx.request.json();
if (interaction.type === "page_load") {
return {
blocks: [
{ type: "header", text: "My Plugin Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.kv.set("settings", interaction.values);
return {
blocks: [/* updated blocks */],
toast: { message: "Settings saved", type: "success" },
};
}
},
},
}
```
## Block Types
| Type | Description |
| --------- | --------------------------------------------------- |
| `header` | Large bold heading |
| `section` | Text with optional accessory element |
| `divider` | Horizontal rule |
| `fields` | Two-column label/value grid |
| `table` | Data table with formatting, sorting, pagination |
| `actions` | Horizontal row of buttons and controls |
| `stats` | Dashboard metric cards with trend indicators |
| `form` | Input fields with conditional visibility and submit |
| `image` | Block-level image with caption |
| `context` | Small muted help text |
| `columns` | 2-3 column layout with nested blocks |
| `chart` | Charts (timeseries line/bar, pie, custom ECharts) |
| `code` | Syntax-highlighted code block |
| `meter` | Progress/quota meter bar |
| `banner` | Info, warning, or error inline messages |
## Element Types
| Type | Description |
| -------------- | ----------------------------------------------- |
| `button` | Action button with optional confirmation dialog |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |
| `checkbox` | Multi-select checkboxes |
| `radio` | Single-select radio buttons |
| `date_input` | Date picker |
| `combobox` | Searchable dropdown select |
## Block Syntax
### Header
```json
{ "type": "header", "text": "Settings" }
```
### Section
```json
{
"type": "section",
"text": "Configure your plugin settings below.",
"accessory": { "type": "button", "text": "Refresh", "action_id": "refresh" }
}
```
### Divider
```json
{ "type": "divider" }
```
### Fields
```json
{
"type": "fields",
"fields": [
{ "label": "Status", "value": "Active" },
{ "label": "Last Sync", "value": "2 hours ago" }
]
}
```
### Stats
```json
{
"type": "stats",
"stats": [
{ "label": "Total", "value": "1,234", "trend": "+12%", "trend_direction": "up" },
{ "label": "Active", "value": "567" }
]
}
```
### Table
```json
{
"type": "table",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "status", "label": "Status" },
{ "key": "date", "label": "Date" }
],
"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }]
}
```
### Actions
```json
{
"type": "actions",
"elements": [
{ "type": "button", "text": "Save", "action_id": "save", "style": "primary" },
{ "type": "button", "text": "Cancel", "action_id": "cancel" }
]
}
```
### Form
```json
{
"type": "form",
"block_id": "settings",
"fields": [
{ "type": "text_input", "action_id": "name", "label": "Name" },
{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
{
"type": "select",
"action_id": "theme",
"label": "Theme",
"options": [
{ "label": "Light", "value": "light" },
{ "label": "Dark", "value": "dark" }
]
},
{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
],
"submit": { "label": "Save", "action_id": "save_settings" }
}
```
### Columns
```json
{
"type": "columns",
"columns": [
{ "blocks": [{ "type": "header", "text": "Left" }] },
{ "blocks": [{ "type": "header", "text": "Right" }] }
]
}
```
### Chart (Timeseries)
```json
{
"type": "chart",
"config": {
"chart_type": "timeseries",
"series": [
{
"name": "Requests",
"data": [
[1709596800000, 42],
[1709600400000, 67],
[1709604000000, 53]
],
"color": "#086FFF"
},
{
"name": "Errors",
"data": [
[1709596800000, 2],
[1709600400000, 5],
[1709604000000, 1]
]
}
],
"x_axis_name": "Time",
"y_axis_name": "Count",
"style": "line",
"gradient": true,
"height": 300
}
}
```
- `series[].data` — array of `[timestamp_ms, value]` tuples
- `series[].color` — hex color (optional, auto-assigned from Kumo palette)
- `style``"line"` (default) or `"bar"`
- `gradient` — fill gradient beneath lines (default false)
- `height` — chart height in pixels (default 350)
### Chart (Custom)
For pie charts, gauges, or any ECharts visualization:
```json
{
"type": "chart",
"config": {
"chart_type": "custom",
"options": {
"series": [
{
"type": "pie",
"data": [
{ "value": 335, "name": "Published" },
{ "value": 234, "name": "Draft" },
{ "value": 120, "name": "Scheduled" }
]
}
]
},
"height": 300
}
}
```
- `options` — raw ECharts option object passed through to `chart.setOption()`
### Code
```json
{
"type": "code",
"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
"language": "ts"
}
```
- `language``"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`)
### Meter
```json
{
"type": "meter",
"label": "Storage used",
"value": 65,
"custom_value": "6.5 GB / 10 GB"
}
```
- `value` — numeric value (default range 0-100)
- `max` / `min` — custom range (defaults to 0-100)
- `custom_value` — display string instead of percentage (e.g. "750 / 1,000")
### Banner
```json
{
"type": "banner",
"title": "API key invalid",
"description": "Please check your API key in settings.",
"variant": "error"
}
```
- `variant``"default"` (info, default), `"alert"` (warning), or `"error"`
- At least one of `title` or `description` is required
## Conditional Fields
Show/hide fields based on other field values. Evaluated client-side, no round-trip.
```json
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
```
```json
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
```
## Builder Helpers
`@emdashcms/blocks` provides TypeScript helpers:
```typescript
import { blocks, elements } from "@emdashcms/blocks";
const { header, form, section, stats, timeseriesChart, customChart, banner: bannerBlock } = blocks;
const { textInput, toggle, select, button } = elements;
return {
blocks: [
header("Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
// Timeseries chart
timeseriesChart({
series: [
{
name: "Page Views",
data: [
[Date.now() - 3600000, 100],
[Date.now(), 150],
],
},
],
yAxisName: "Views",
gradient: true,
}),
// Pie chart via custom ECharts options
customChart({
options: {
series: [
{
type: "pie",
data: [
{ value: 335, name: "Published" },
{ value: 234, name: "Draft" },
],
},
],
},
}),
],
};
```
## Button Confirmations
```json
{
"type": "button",
"text": "Delete All",
"action_id": "delete_all",
"style": "danger",
"confirm": {
"title": "Are you sure?",
"text": "This cannot be undone.",
"confirm": "Delete",
"deny": "Cancel"
}
}
```
## Toast Responses
Return a `toast` alongside blocks to show a notification:
```typescript
return {
blocks: [
/* ... */
],
toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
};
```

View File

@@ -0,0 +1,412 @@
# Hooks Reference
Hooks let plugins run code in response to events. Declared in `definePlugin({ hooks })`.
## Signature
```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```
## Configuration
Simple handler or full config:
```typescript
// Simple
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Saved");
}
}
// Full config
hooks: {
"content:afterSave": {
priority: 100, // Lower runs first (default: 100)
timeout: 5000, // Max execution time ms (default: 5000)
dependencies: [], // Plugin IDs that must run first
errorPolicy: "abort", // "abort" | "continue"
handler: async (event, ctx) => {
ctx.log.info("Saved");
}
}
}
```
## Lifecycle Hooks
### `plugin:install`
Runs once on first install. Use to seed defaults.
```typescript
"plugin:install": async (_event, ctx) => {
await ctx.kv.set("settings:enabled", true);
await ctx.storage.items!.put("default", { name: "Default" });
}
```
Event: `{}`
Returns: `void`
### `plugin:activate`
Runs when plugin is enabled (after install or re-enable).
```typescript
"plugin:activate": async (_event, ctx) => {
ctx.log.info("Activated");
}
```
Event: `{}`
Returns: `void`
### `plugin:deactivate`
Runs when plugin is disabled (not removed).
```typescript
"plugin:deactivate": async (_event, ctx) => {
ctx.log.info("Deactivated");
}
```
Event: `{}`
Returns: `void`
### `plugin:uninstall`
Runs when plugin is removed. Only delete data if `event.deleteData` is true.
```typescript
"plugin:uninstall": async (event, ctx) => {
if (event.deleteData) {
const result = await ctx.storage.items!.query({ limit: 1000 });
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
}
}
```
Event: `{ deleteData: boolean }`
Returns: `void`
## Content Hooks
### `content:beforeSave`
Runs before save. Return modified content, void to keep unchanged, or throw to cancel.
```typescript
"content:beforeSave": async (event, ctx) => {
const { content, collection, isNew } = event;
if (collection === "posts" && !content.title) {
throw new Error("Posts require a title");
}
// Transform
if (content.slug) {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
}
return content;
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
Returns: `Record<string, unknown> | void`
### `content:afterSave`
Runs after successful save. Side effects only — logging, notifications, syncing.
```typescript
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean }`
Returns: `void`
### `content:beforeDelete`
Runs before delete. Return `false` to cancel, `true` or void to allow.
```typescript
"content:beforeDelete": async (event, ctx) => {
if (event.collection === "pages" && event.id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
}
```
Event: `{ id: string, collection: string }`
Returns: `boolean | void`
### `content:afterDelete`
Runs after successful delete.
```typescript
"content:afterDelete": async (event, ctx) => {
ctx.log.info(`Deleted ${event.collection}/${event.id}`);
await ctx.storage.cache!.delete(`${event.collection}:${event.id}`);
}
```
Event: `{ id: string, collection: string }`
Returns: `void`
## Media Hooks
### `media:beforeUpload`
Runs before upload. Return modified file info, void to keep, or throw to cancel.
```typescript
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
if (!file.type.startsWith("image/")) {
throw new Error("Only images allowed");
}
if (file.size > 10 * 1024 * 1024) {
throw new Error("Max 10MB");
}
return { ...file, name: `${Date.now()}-${file.name}` };
}
```
Event: `{ file: { name: string, type: string, size: number } }`
Returns: `{ name: string, type: string, size: number } | void`
### `media:afterUpload`
Runs after successful upload.
```typescript
"media:afterUpload": async (event, ctx) => {
ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
}
```
Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }`
Returns: `void`
## Email Hooks
Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.
### `email:beforeSend`
**Requires:** `email:intercept` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
definePlugin({
id: "email-footer",
capabilities: ["email:intercept"],
hooks: {
"email:beforeSend": async (event, ctx) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
},
},
});
```
Event: `{ message: EmailMessage, source: string }`
Returns: `EmailMessage | false`
### `email:deliver`
**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
definePlugin({
id: "emdash-resend",
capabilities: ["email:provide", "network:fetch"],
allowedHosts: ["api.resend.com"],
hooks: {
"email:deliver": {
exclusive: true,
handler: async ({ message }, ctx) => {
const apiKey = await ctx.kv.get("settings:apiKey");
await ctx.http!.fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({ to: message.to, subject: message.subject, text: message.text }),
});
},
},
},
});
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
### `email:afterSend`
**Requires:** `email:intercept` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
definePlugin({
id: "email-logger",
capabilities: ["email:intercept"],
hooks: {
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
},
},
});
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
## Cron Hook
### `cron`
Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`.
```typescript
definePlugin({
id: "cleanup",
hooks: {
"plugin:activate": async (_event, ctx) => {
await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
},
cron: async (event, ctx) => {
if (event.name === "daily-cleanup") {
// ... cleanup logic
}
},
},
});
```
Event: `{ name: string, data?: Record<string, unknown> }`
Returns: `void`
## Public Page Hooks
Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components.
### `page:metadata`
Contributes typed metadata to `<head>` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.
Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.
```typescript
"page:metadata": async (event, ctx) => {
if (event.page.kind !== "content") return null;
return [
{ kind: "meta", name: "author", content: "My Blog" },
{
kind: "jsonld",
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
graph: {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: event.page.title,
description: event.page.description,
},
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageMetadataContribution | PageMetadataContribution[] | null`
Contribution types:
- `{ kind: "meta", name: string, content: string, key?: string }``<meta name="..." content="...">`
- `{ kind: "property", property: string, content: string, key?: string }``<meta property="..." content="...">` (OpenGraph)
- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }``<link>` tag (HTTP/HTTPS URLs only)
- `{ kind: "jsonld", id?: string, graph: object | object[] }``<script type="application/ld+json">`
Dedupe rules: first contribution wins per key. Canonical is singleton.
### `page:fragments` (Trusted Only)
Contributes raw HTML, scripts, or markup to `head`, `body:start`, or `body:end`. **Trusted plugins only.** Sandboxed plugins cannot register this hook — the manifest schema rejects it.
```typescript
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "head",
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
async: true,
},
{
kind: "html",
placement: "body:start",
html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageFragmentContribution | PageFragmentContribution[] | null`
Contribution types:
- `{ kind: "external-script", placement, src, async?, defer?, attributes?, key? }`
- `{ kind: "inline-script", placement, code, attributes?, key? }`
- `{ kind: "html", placement, html, key? }`
Placements: `"head"`, `"body:start"`, `"body:end"`
## Execution Order
1. Lower `priority` values run first
2. Equal priorities: plugin registration order
3. `dependencies` array forces ordering regardless of priority
## Error Handling
- `errorPolicy: "abort"` (default) — pipeline stops, operation may fail
- `errorPolicy: "continue"` — error logged, remaining hooks still run
Use `"continue"` for non-critical operations (analytics, notifications, external syncs).
## Quick Reference
| Hook | Trigger | Capability Required | Return |
| ---------------------- | -------------------- | ------------------- | ---------------------------- |
| `plugin:install` | First install | — | `void` |
| `plugin:activate` | Plugin enabled | — | `void` |
| `plugin:deactivate` | Plugin disabled | — | `void` |
| `plugin:uninstall` | Plugin removed | — | `void` |
| `content:beforeSave` | Before save | — | Modified content or `void` |
| `content:afterSave` | After save | — | `void` |
| `content:beforeDelete` | Before delete | — | `false` to cancel |
| `content:afterDelete` | After delete | — | `void` |
| `media:beforeUpload` | Before upload | — | Modified file info or `void` |
| `media:afterUpload` | After upload | — | `void` |
| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` |
| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) |
| `email:afterSend` | After email send | `email:intercept` | `void` |
| `cron` | Scheduled task fires | — | `void` |
| `page:metadata` | Page render | — | Metadata contributions |
| `page:fragments` | Page render | — (trusted only) | Fragment contributions |

View File

@@ -0,0 +1,251 @@
# Portable Text Block Types
**Trusted plugins only.** PT blocks require Astro components for site-side rendering (`componentsEntry`), loaded at build time from an npm package. Sandboxed/marketplace plugins cannot define PT blocks.
Plugins can add custom block types to the Portable Text editor. These appear in the slash command menu and can be inserted into any `portableText` field.
## Declaring Block Types
In `definePlugin()`, declare blocks under `admin.portableTextBlocks`:
```typescript
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
{
type: "codepen",
label: "CodePen",
icon: "code",
placeholder: "Paste CodePen URL...",
},
],
}
```
### Block Config Fields
| Field | Type | Description |
| ------------- | -------- | ----------------------------------------------- |
| `type` | `string` | Block type name (used in PT `_type`). Required. |
| `label` | `string` | Display name in slash command menu. Required. |
| `icon` | `string` | Icon key. Optional. |
| `description` | `string` | Description in slash command menu. Optional. |
| `placeholder` | `string` | Input placeholder text. Optional. |
| `fields` | `array` | Block Kit form fields for editing UI. Optional. |
### Icons
Named icons: `video`, `code`, `link`, `link-external`. Unknown or missing falls back to a generic cube icon.
### Fields
When `fields` is declared, the editor renders a Block Kit form for editing. When omitted, a simple URL input is shown.
Fields use Block Kit element syntax:
```typescript
fields: [
{
type: "text_input",
action_id: "id",
label: "URL",
placeholder: "https://...",
},
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image" },
{ type: "number_input", action_id: "start", label: "Start Time (seconds)" },
{ type: "toggle", action_id: "autoplay", label: "Autoplay" },
{
type: "select",
action_id: "size",
label: "Size",
options: [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
],
},
];
```
See [Block Kit reference](./block-kit.md) for all element types.
The `action_id` of each field becomes a key in the Portable Text block data. The field with `action_id: "id"` is treated as the primary identifier (typically the URL).
### Data Flow
1. User types `/` in the editor and selects a block type
2. Modal opens with Block Kit form (or simple URL input if no fields)
3. User fills in fields and submits
4. Block is inserted with `_type` set to the block type and field values as properties
5. Editing an existing block re-opens the modal pre-populated
Portable Text output:
```json
{
"_type": "youtube",
"_key": "abc123",
"id": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"poster": "https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg"
}
```
## Site-Side Rendering
To render block types on the site, export Astro components from a `componentsEntry`.
### Component File
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// This export name is required
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
```
### Astro Component
```astro
---
// src/astro/YouTube.astro
const { id, title, poster } = Astro.props.node;
// Extract video ID from URL
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
---
<div class="youtube-embed">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title || "YouTube Video"}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
```
Component receives `Astro.props.node` with the full block data.
### Plugin Descriptor
Set `componentsEntry` in the descriptor:
```typescript
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
version: "1.0.0",
options,
};
}
```
### Package Exports
Add the `./astro` export:
```json
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
}
}
```
### Auto-Wiring
Plugin block components are automatically merged into `<PortableText>` on the site. Merge order:
1. EmDash defaults (lowest priority)
2. Plugin block components
3. User-provided components (highest priority)
Site authors don't need to import anything. User components take precedence over plugin defaults.
## Complete Example
```typescript
// src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export function embedsPlugin(options = {}): PluginDescriptor {
return {
id: "embeds",
version: "1.0.0",
entrypoint: "@my-org/plugin-embeds",
componentsEntry: "@my-org/plugin-embeds/astro",
options,
};
}
export function createPlugin() {
return definePlugin({
id: "embeds",
version: "1.0.0",
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{
type: "text_input",
action_id: "poster",
label: "Poster Image URL",
},
],
},
{
type: "linkPreview",
label: "Link Preview",
icon: "link-external",
placeholder: "Paste any URL...",
},
],
},
});
}
export default createPlugin;
```
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import LinkPreview from "./LinkPreview.astro";
export const blockComponents = {
youtube: YouTube,
linkPreview: LinkPreview,
};
```

View File

@@ -0,0 +1,82 @@
# Publishing to the Marketplace
Sandboxed plugins can be published to the EmDash Marketplace for one-click installation from the admin UI.
## Bundle Format
Published plugins are `.tar.gz` tarballs:
| File | Required | Description |
| --------------- | -------- | ----------------------------------------------- |
| `manifest.json` | Yes | Metadata extracted from `definePlugin()` |
| `backend.js` | No | Bundled sandbox code (self-contained ES module) |
| `admin.js` | No | Bundled admin UI code |
| `README.md` | No | Plugin documentation |
| `icon.png` | No | Plugin icon (256x256 PNG) |
| `screenshots/` | No | Up to 5 screenshots (PNG/JPEG, max 1920x1080) |
## Package Exports for Bundling
The bundle command uses `package.json` exports to find entrypoints:
```json
{
"exports": {
".": { "import": "./dist/index.mjs" },
"./sandbox": { "import": "./dist/sandbox-entry.mjs" },
"./admin": { "import": "./dist/admin.mjs" }
}
}
```
| Export | Purpose | Built as |
| ------------- | ----------------------------- | ------------------------------------ |
| `"."` | Main entry — extract manifest | Externals: `emdash`, `@emdashcms/*` |
| `"./sandbox"` | Backend code for the sandbox | Fully self-contained (no externals) |
| `"./admin"` | Admin UI components | Fully self-contained |
If `"./sandbox"` is missing, the command looks for `src/sandbox-entry.ts`.
## Build and Publish
```bash
# Bundle only (inspect first)
emdash plugin bundle
tar tzf dist/my-plugin-1.0.0.tar.gz
# Publish (uploads to marketplace)
emdash plugin publish
# Build + publish in one step
emdash plugin publish --build
```
First-time publish authenticates via GitHub device authorization. Token stored in `~/.config/emdash/auth.json` (30-day expiry).
## Validation
The bundle command checks:
- **Size limit** — Total bundle under 5MB
- **No Node.js built-ins** — `backend.js` cannot import `fs`, `path`, etc.
- **Sandbox-incompatible features** — Warns if the plugin declares `portableTextBlocks`, `admin.entry` (React components), or API `routes`, since these require trusted mode
- **Icon dimensions** — 256x256 PNG (warns if wrong)
- **Screenshot limits** — Max 5, max 1920x1080
## Security Audit
Every published version is automatically audited for:
- Data exfiltration patterns
- Credential harvesting via settings
- Obfuscated code
- Resource abuse (crypto mining, etc.)
- Suspicious network activity
Verdict: **pass**, **warn**, or **fail** — displayed on marketplace listing.
## Version Requirements
- Each version must have higher semver than the last
- Cannot overwrite or republish an existing version
- Plugin ID is auto-registered on first publish

View File

@@ -0,0 +1,264 @@
# Storage, KV & Settings
Plugins have three data mechanisms:
| Mechanism | Purpose | Access |
| ------------------- | ----------------------------------------- | ---------------------- |
| **Storage** | Document collections with indexed queries | `ctx.storage` |
| **KV** | Key-value pairs for state and settings | `ctx.kv` |
| **Settings Schema** | Auto-generated admin UI for configuration | `admin.settingsSchema` |
## Storage Collections
Declare in `definePlugin({ storage })`. EmDash creates the schema automatically — no migrations.
```typescript
definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Single-field index
"status",
"createdAt",
["formId", "createdAt"], // Composite index
],
},
forms: {
indexes: ["slug"],
},
},
});
```
Storage is scoped to the plugin — `submissions` in plugin `forms` is separate from `submissions` in another plugin.
### CRUD
```typescript
const { submissions } = ctx.storage;
await submissions.put("sub_123", { formId: "contact", email: "user@example.com" });
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
await submissions.delete("sub_123");
```
### Batch Operations
```typescript
const items = await submissions.getMany(["sub_1", "sub_2"]); // Map<string, T>
await submissions.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
const deletedCount = await submissions.deleteMany(["sub_1", "sub_2"]);
```
### Querying
Only indexed fields can be queried. Non-indexed queries throw.
```typescript
const result = await ctx.storage.submissions.query({
where: {
formId: "contact",
status: "pending",
},
orderBy: { createdAt: "desc" },
limit: 20,
});
// result.items - Array of { id, data }
// result.cursor - Pagination cursor
// result.hasMore - Boolean
```
### Where Operators
```typescript
// Exact match
where: { status: "pending" }
// Range
where: { createdAt: { gte: "2024-01-01" } }
where: { score: { gt: 50, lte: 100 } }
// In
where: { status: { in: ["pending", "approved"] } }
// Starts with
where: { slug: { startsWith: "blog-" } }
```
### Pagination
```typescript
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions!.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
// process result.items
cursor = result.cursor;
} while (cursor);
```
### Counting
```typescript
const total = await ctx.storage.submissions!.count();
const pending = await ctx.storage.submissions!.count({ status: "pending" });
```
### Index Design
| Query Pattern | Index Needed |
| ---------------------------------------- | ------------------------- |
| Filter by `formId` | `"formId"` |
| Filter by `formId`, order by `createdAt` | `["formId", "createdAt"]` |
| Order by `createdAt` only | `"createdAt"` |
Composite indexes support filtering on the first field + ordering by the second.
### Type Safety
```typescript
interface Submission {
formId: string;
status: "pending" | "approved" | "spam";
createdAt: string;
}
// Cast in hook/route handlers
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
```
### Full API
```typescript
interface StorageCollection<T = unknown> {
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
count(where?: WhereClause): Promise<number>;
}
```
## KV Store
General-purpose key-value store. Use for internal state, cached computations, or programmatic access to settings.
```typescript
interface KVAccess {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}
```
### Key Naming Conventions
| Prefix | Purpose | Example |
| ----------- | ----------------------------- | ----------------- |
| `settings:` | User-configurable preferences | `settings:apiKey` |
| `state:` | Internal plugin state | `state:lastSync` |
| `cache:` | Cached data | `cache:results` |
```typescript
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", new Date().toISOString());
const allSettings = await ctx.kv.list("settings:");
```
## Settings Schema
Declare `admin.settingsSchema` to auto-generate a settings form in the admin UI:
```typescript
admin: {
settingsSchema: {
siteTitle: {
type: "string",
label: "Site Title",
description: "Used in title tags",
default: "",
},
maxItems: {
type: "number",
label: "Max Items",
default: 100,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: true,
},
theme: {
type: "select",
label: "Theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
],
default: "light",
},
apiKey: {
type: "secret",
label: "API Key",
description: "Encrypted at rest",
},
},
}
```
### Setting Types
| Type | UI | Notes |
| --------- | ------------ | ----------------------------------------- |
| `string` | Text input | Optional `multiline: true` for textarea |
| `number` | Number input | Optional `min`, `max` |
| `boolean` | Toggle | |
| `select` | Dropdown | Requires `options: [{ value, label }]` |
| `secret` | Masked input | Encrypted at rest, never shown after save |
### Reading Settings
Settings are accessed via KV with `settings:` prefix:
```typescript
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
const apiKey = await ctx.kv.get<string>("settings:apiKey");
```
Schema defaults are UI defaults only — not auto-persisted. Handle missing values with `??` or persist defaults in `plugin:install`:
```typescript
"plugin:install": async (_event, ctx) => {
await ctx.kv.set("settings:enabled", true);
await ctx.kv.set("settings:maxItems", 100);
}
```
## When to Use What
| Use Case | Mechanism |
| -------------------------------------------- | --------------------------------- |
| Admin-editable preferences | `settingsSchema` + KV `settings:` |
| Internal state (timestamps, cursors) | KV `state:` |
| Collections of documents (logs, submissions) | Storage |
| Cached computations | KV `cache:` |