first commit
This commit is contained in:
@@ -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" }],
|
||||
};
|
||||
}
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
};
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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,
|
||||
};
|
||||
```
|
||||
@@ -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
|
||||
@@ -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:` |
|
||||
Reference in New Issue
Block a user