Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

21
docs/.gitignore vendored Normal file
View File

@@ -0,0 +1,21 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store

4
docs/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"recommendations": ["astro-build.astro-vscode"],
"unwantedRecommendations": []
}

11
docs/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.2.0",
"configurations": [
{
"command": "./node_modules/.bin/astro dev",
"name": "Development server",
"request": "launch",
"type": "node-terminal"
}
]
}

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# EmDash Docs
Documentation site for EmDash, built with [Starlight](https://starlight.astro.build).
## Development
```bash
pnpm dev
```
## Build
```bash
pnpm build
```

175
docs/astro.config.mjs Normal file
View File

@@ -0,0 +1,175 @@
import cloudflare from "@astrojs/cloudflare";
import starlight from "@astrojs/starlight";
// @ts-check
import { defineConfig } from "astro/config";
// https://astro.build/config
export default defineConfig({
site: "https://docs.emdashcms.com",
integrations: [
starlight({
title: "EmDash",
tagline: "The Astro-native CMS",
logo: {
light: "./src/assets/logo-light.svg",
dark: "./src/assets/logo-dark.svg",
replacesTitle: true,
},
social: [
{
icon: "github",
label: "GitHub",
href: "https://github.com/emdash-cms/emdash",
},
],
editLink: {
baseUrl: "https://github.com/emdash-cms/emdash/tree/main/docs",
},
customCss: ["./src/styles/custom.css"],
sidebar: [
{
label: "Start Here",
items: [
{ label: "Introduction", slug: "introduction" },
{ label: "Getting Started", slug: "getting-started" },
{ label: "Why EmDash?", slug: "why-emdash" },
{ label: "Docs MCP for AI Tools", slug: "docs-mcp" },
],
},
{
label: "Coming From...",
items: [
{
label: "EmDash for WordPress Developers",
slug: "coming-from/wordpress",
},
{
label: "Astro for WordPress Developers",
slug: "coming-from/astro-for-wp-devs",
},
{
label: "EmDash for Astro Developers",
slug: "coming-from/astro",
},
],
},
{
label: "Guides",
items: [
{ label: "Create a Blog", slug: "guides/create-a-blog" },
{
label: "Working with Content",
slug: "guides/working-with-content",
},
{ label: "Querying Content", slug: "guides/querying-content" },
{ label: "Media Library", slug: "guides/media-library" },
{ label: "Taxonomies", slug: "guides/taxonomies" },
{ label: "Navigation Menus", slug: "guides/menus" },
{ label: "Widget Areas", slug: "guides/widgets" },
{ label: "Page Layouts", slug: "guides/page-layouts" },
{ label: "Sections", slug: "guides/sections" },
{ label: "Site Settings", slug: "guides/site-settings" },
{ label: "Authentication", slug: "guides/authentication" },
{ label: "Atmosphere Login", slug: "guides/atmosphere-auth" },
{ label: "AI Tools", slug: "guides/ai-tools" },
{ label: "x402 Payments", slug: "guides/x402-payments" },
{ label: "Preview Mode", slug: "guides/preview" },
{
label: "Internationalization (i18n)",
slug: "guides/internationalization",
},
],
},
{
label: "Plugins",
items: [
{ label: "Plugin Overview", slug: "plugins/overview" },
{ label: "Creating Plugins", slug: "plugins/creating-plugins" },
{ label: "Plugin Hooks", slug: "plugins/hooks" },
{ label: "Plugin Storage", slug: "plugins/storage" },
{ label: "Plugin Settings", slug: "plugins/settings" },
{ label: "Admin UI Extensions", slug: "plugins/admin-ui" },
{ label: "Block Kit", slug: "plugins/block-kit" },
{ label: "Field Kit", slug: "plugins/field-kit" },
{ label: "API Routes", slug: "plugins/api-routes" },
{ label: "Sandbox & Security", slug: "plugins/sandbox" },
{ label: "Publishing Plugins", slug: "plugins/publishing" },
{ label: "Installing Plugins", slug: "plugins/installing" },
],
},
{
label: "Contributing",
collapsed: true,
items: [
{ label: "Contributor Guide", slug: "contributing" },
{ label: "Translating EmDash", slug: "contributing/translating" },
],
},
{
label: "Themes",
items: [
{ label: "Themes Overview", slug: "themes/overview" },
{
label: "Creating Themes",
slug: "themes/creating-themes",
},
{ label: "Seed File Format", slug: "themes/seed-files" },
{
label: "Porting WordPress Themes",
slug: "themes/porting-wp-themes",
},
],
},
{
label: "Migration",
items: [
{
label: "Migrate from WordPress",
slug: "migration/from-wordpress",
},
{ label: "Content Import", slug: "migration/content-import" },
{
label: "Porting WordPress Plugins",
slug: "migration/porting-plugins",
},
],
},
{
label: "Deployment",
items: [
{ label: "Deploy to Cloudflare", slug: "deployment/cloudflare" },
{ label: "Deploy to Node.js", slug: "deployment/nodejs" },
{ label: "Database Options", slug: "deployment/database" },
{ label: "Storage Options", slug: "deployment/storage" },
],
},
{
label: "Concepts",
collapsed: true,
items: [
{ label: "Architecture", slug: "concepts/architecture" },
{ label: "Collections", slug: "concepts/collections" },
{ label: "Content Model", slug: "concepts/content-model" },
{ label: "The Admin Panel", slug: "concepts/admin-panel" },
],
},
{
label: "Reference",
collapsed: true,
items: [
{ label: "Configuration", slug: "reference/configuration" },
{ label: "CLI Commands", slug: "reference/cli" },
{ label: "API Reference", slug: "reference/api" },
{ label: "Field Types", slug: "reference/field-types" },
{ label: "Hook Reference", slug: "reference/hooks" },
{ label: "REST API", slug: "reference/rest-api" },
{ label: "MCP Server", slug: "reference/mcp-server" },
],
},
],
}),
],
adapter: cloudflare(),
});

30
docs/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "docs",
"private": true,
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"generate-types": "wrangler types"
},
"dependencies": {
"@astrojs/cloudflare": "^13.1.7",
"@astrojs/starlight": "^0.38.2",
"@astrojs/starlight-tailwind": "^5.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"agents": "^0.12.0",
"astro": "^6.1.3",
"sharp": "^0.34.5",
"starlight-utils": "^1.0.0",
"tailwindcss": "^4.1.18",
"wrangler": "catalog:",
"zod": "^4.4.1"
},
"overrides": {
"vite": "^7"
}
}

12
docs/public/favicon.svg Normal file
View File

@@ -0,0 +1,12 @@
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="69" height="69" rx="10.518" stroke="url(#fav-border)" stroke-width="6"/>
<rect x="18" y="34" width="39.3661" height="6.56101" fill="url(#fav-dash)"/>
<defs>
<linearGradient id="fav-border" x1="-42.9996" y1="124" x2="92.4233" y2="-41.7456" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="fav-dash" x1="91.4992" y1="27.4982" x2="28.1217" y2="54.1775" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

4
docs/public/robots.txt Normal file
View File

@@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://docs.emdashcms.com/sitemap-index.xml

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,18 @@
<svg width="471" height="118" viewBox="0 0 471 118" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.410156 96.5125V21.2097C0.410156 9.48841 9.91245 -0.013916 21.6338 -0.013916V9.40601L21.3291 9.40991C14.9509 9.57133 9.83008 14.7927 9.83008 21.2097V96.5125C9.83008 102.93 14.9509 108.151 21.3291 108.312L21.6338 108.316H96.9365L97.2412 108.312C103.518 108.153 108.577 103.094 108.736 96.8171L108.74 96.5125V21.2097C108.74 14.6909 103.455 9.40601 96.9365 9.40601V-0.013916C108.658 -0.013916 118.16 9.48838 118.16 21.2097V96.5125C118.16 108.234 108.658 117.736 96.9365 117.736H21.6338C9.91248 117.736 0.410156 108.234 0.410156 96.5125ZM96.9365 -0.013916V9.40601H21.6338V-0.013916H96.9365Z" fill="url(#ld-icon)"/>
<path d="M28.6699 53.366H90.4746V63.6668H28.6699V53.366Z" fill="url(#ld-dash)"/>
<path d="M154.762 90V27.4834H194.447V35.8449H164.467V54.0844H192.844V62.2293H164.467V81.6385H194.447V90H154.762Z" fill="white"/>
<path d="M204.172 90V44.4231H213.53V51.4849H213.747C215.697 46.7193 220.332 43.5566 226.311 43.5566C232.593 43.5566 237.185 46.8059 239.005 52.5247H239.222C241.561 46.9792 246.933 43.5566 253.432 43.5566C262.443 43.5566 268.335 49.5353 268.335 58.6767V90H258.934V60.9296C258.934 54.9942 255.771 51.5716 250.226 51.5716C244.68 51.5716 240.825 55.7307 240.825 61.4928V90H231.64V60.2364C231.64 54.9508 228.304 51.5716 223.018 51.5716C217.473 51.5716 213.53 55.9473 213.53 61.8394V90H204.172Z" fill="white"/>
<path d="M279.404 90V27.4834H301.456C319.998 27.4834 331.046 38.8776 331.046 58.5467V58.6334C331.046 78.3892 320.085 90 301.456 90H279.404ZM289.108 81.5951H300.546C313.803 81.5951 321.125 73.4935 321.125 58.72V58.6334C321.125 43.9465 313.716 35.8449 300.546 35.8449H289.108V81.5951Z" fill="white"/>
<path d="M353.379 90.8232C344.281 90.8232 338.172 85.2344 338.172 77.0461V76.9595C338.172 69.0312 344.324 64.1789 355.112 63.529L367.502 62.7925V59.3699C367.502 54.3443 364.253 51.3116 358.448 51.3116C353.032 51.3116 349.696 53.8677 348.916 57.507L348.83 57.8969H339.992L340.035 57.4203C340.685 49.5787 347.487 43.5566 358.708 43.5566C369.842 43.5566 376.904 49.4487 376.904 58.5901V90H367.502V82.8082H367.329C364.686 87.7038 359.401 90.8232 353.379 90.8232ZM347.617 76.8295C347.617 80.8153 350.909 83.3281 355.935 83.3281C362.52 83.3281 367.502 78.8657 367.502 72.9303V69.3778L356.368 70.0709C350.736 70.4175 347.617 72.887 347.617 76.7428V76.8295Z" fill="white"/>
<path d="M403.959 90.9098C392.564 90.9098 385.893 85.2777 384.939 76.9595L384.896 76.5695H394.167L394.254 77.0028C395.121 81.2052 398.24 83.6747 404.002 83.6747C409.634 83.6747 413.013 81.3352 413.013 77.6527V77.6093C413.013 74.6633 411.367 72.9737 406.471 71.8039L399.02 70.1143C390.355 68.1214 386.066 63.9623 386.066 57.3337V57.2903C386.066 49.1454 393.171 43.5566 403.655 43.5566C414.443 43.5566 420.942 49.5787 421.418 57.3337L421.462 57.8536H412.667L412.624 57.5503C412.06 53.5645 408.941 50.7917 403.655 50.7917C398.63 50.7917 395.467 53.1746 395.467 56.8138V56.8571C395.467 59.6732 397.33 61.5794 402.226 62.7492L409.634 64.4388C418.949 66.605 422.501 70.2876 422.501 76.8295V76.8728C422.501 85.191 414.703 90.9098 403.959 90.9098Z" fill="white"/>
<path d="M431.014 90V27.4834H440.372V51.9182H440.588C443.014 46.6326 447.91 43.5566 454.712 43.5566C464.46 43.5566 470.872 50.8351 470.872 61.8394V90H461.514V63.6157C461.514 56.0773 457.701 51.5716 451.116 51.5716C444.661 51.5716 440.372 56.5105 440.372 63.6157V90H431.014Z" fill="white"/>
<defs>
<linearGradient id="ld-icon" x1="-67.1002" y1="194.666" x2="145.514" y2="-65.5554" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="ld-dash" x1="144.064" y1="43.1581" x2="44.5609" y2="85.0447" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

@@ -0,0 +1,18 @@
<svg width="471" height="118" viewBox="0 0 471 118" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.410156 96.5125V21.2097C0.410156 9.48841 9.91245 -0.013916 21.6338 -0.013916V9.40601L21.3291 9.40991C14.9509 9.57133 9.83008 14.7927 9.83008 21.2097V96.5125C9.83008 102.93 14.9509 108.151 21.3291 108.312L21.6338 108.316H96.9365L97.2412 108.312C103.518 108.153 108.577 103.094 108.736 96.8171L108.74 96.5125V21.2097C108.74 14.6909 103.455 9.40601 96.9365 9.40601V-0.013916C108.658 -0.013916 118.16 9.48838 118.16 21.2097V96.5125C118.16 108.234 108.658 117.736 96.9365 117.736H21.6338C9.91248 117.736 0.410156 108.234 0.410156 96.5125ZM96.9365 -0.013916V9.40601H21.6338V-0.013916H96.9365Z" fill="url(#ll-icon)"/>
<path d="M28.6699 53.366H90.4746V63.6668H28.6699V53.366Z" fill="url(#ll-dash)"/>
<path d="M154.762 90V27.4834H194.447V35.8449H164.467V54.0844H192.844V62.2293H164.467V81.6385H194.447V90H154.762Z" fill="#0B0152"/>
<path d="M204.172 90V44.4231H213.53V51.4849H213.747C215.697 46.7193 220.332 43.5566 226.311 43.5566C232.593 43.5566 237.185 46.8059 239.005 52.5247H239.222C241.561 46.9792 246.933 43.5566 253.432 43.5566C262.443 43.5566 268.335 49.5353 268.335 58.6767V90H258.934V60.9296C258.934 54.9942 255.771 51.5716 250.226 51.5716C244.68 51.5716 240.825 55.7307 240.825 61.4928V90H231.64V60.2364C231.64 54.9508 228.304 51.5716 223.018 51.5716C217.473 51.5716 213.53 55.9473 213.53 61.8394V90H204.172Z" fill="#0B0152"/>
<path d="M279.404 90V27.4834H301.456C319.998 27.4834 331.046 38.8776 331.046 58.5467V58.6334C331.046 78.3892 320.085 90 301.456 90H279.404ZM289.108 81.5951H300.546C313.803 81.5951 321.125 73.4935 321.125 58.72V58.6334C321.125 43.9465 313.716 35.8449 300.546 35.8449H289.108V81.5951Z" fill="#0B0152"/>
<path d="M353.379 90.8232C344.281 90.8232 338.172 85.2344 338.172 77.0461V76.9595C338.172 69.0312 344.324 64.1789 355.112 63.529L367.502 62.7925V59.3699C367.502 54.3443 364.253 51.3116 358.448 51.3116C353.032 51.3116 349.696 53.8677 348.916 57.507L348.83 57.8969H339.992L340.035 57.4203C340.685 49.5787 347.487 43.5566 358.708 43.5566C369.842 43.5566 376.904 49.4487 376.904 58.5901V90H367.502V82.8082H367.329C364.686 87.7038 359.401 90.8232 353.379 90.8232ZM347.617 76.8295C347.617 80.8153 350.909 83.3281 355.935 83.3281C362.52 83.3281 367.502 78.8657 367.502 72.9303V69.3778L356.368 70.0709C350.736 70.4175 347.617 72.887 347.617 76.7428V76.8295Z" fill="#0B0152"/>
<path d="M403.959 90.9098C392.564 90.9098 385.893 85.2777 384.939 76.9595L384.896 76.5695H394.167L394.254 77.0028C395.121 81.2052 398.24 83.6747 404.002 83.6747C409.634 83.6747 413.013 81.3352 413.013 77.6527V77.6093C413.013 74.6633 411.367 72.9737 406.471 71.8039L399.02 70.1143C390.355 68.1214 386.066 63.9623 386.066 57.3337V57.2903C386.066 49.1454 393.171 43.5566 403.655 43.5566C414.443 43.5566 420.942 49.5787 421.418 57.3337L421.462 57.8536H412.667L412.624 57.5503C412.06 53.5645 408.941 50.7917 403.655 50.7917C398.63 50.7917 395.467 53.1746 395.467 56.8138V56.8571C395.467 59.6732 397.33 61.5794 402.226 62.7492L409.634 64.4388C418.949 66.605 422.501 70.2876 422.501 76.8295V76.8728C422.501 85.191 414.703 90.9098 403.959 90.9098Z" fill="#0B0152"/>
<path d="M431.014 90V27.4834H440.372V51.9182H440.588C443.014 46.6326 447.91 43.5566 454.712 43.5566C464.46 43.5566 470.872 50.8351 470.872 61.8394V90H461.514V63.6157C461.514 56.0773 457.701 51.5716 451.116 51.5716C444.661 51.5716 440.372 56.5105 440.372 63.6157V90H431.014Z" fill="#0B0152"/>
<defs>
<linearGradient id="ll-icon" x1="-67.1002" y1="194.666" x2="145.514" y2="-65.5554" gradientUnits="userSpaceOnUse">
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
</linearGradient>
<linearGradient id="ll-dash" x1="144.064" y1="43.1581" x2="44.5609" y2="85.0447" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

@@ -0,0 +1,7 @@
import { docsLoader } from "@astrojs/starlight/loaders";
import { docsSchema } from "@astrojs/starlight/schema";
import { defineCollection } from "astro:content";
export const collections = {
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
};

View File

@@ -0,0 +1,541 @@
---
title: Astro for WordPress Developers
description: Learn Astro fundamentals through the lens of WordPress concepts you already know
---
import { Aside, Card, CardGrid, TabItem, Tabs } from "@astrojs/starlight/components";
Astro is a web framework for building content-focused websites. When using EmDash, Astro replaces your WordPress theme—it handles templating, routing, and rendering.
This guide teaches Astro fundamentals by mapping them to WordPress concepts you already understand.
## Key Paradigm Shifts
<CardGrid>
<Card title="Server-rendered by default" icon="laptop">
Like PHP, Astro code runs on the server. Unlike PHP, it outputs static HTML by default with zero
JavaScript.
</Card>
<Card title="Zero JS unless you add it" icon="rocket">
WordPress loads jQuery and theme scripts automatically. Astro ships nothing to the browser
unless you explicitly add it.
</Card>
<Card title="Component-based architecture" icon="puzzle">
Instead of scattered template tags and includes, build with composable, self-contained
components.
</Card>
<Card title="File-based routing" icon="document">
No rewrite rules or `query_vars`. The file structure in `src/pages/` defines your URLs directly.
</Card>
</CardGrid>
## Project Structure
WordPress themes have a flat structure with magic filenames. Astro uses explicit directories:
| WordPress | Astro | Purpose |
| --------------------------- | ------------------ | ------------------ |
| `index.php`, `single.php` | `src/pages/` | Routes (URLs) |
| `template-parts/` | `src/components/` | Reusable UI pieces |
| `header.php` + `footer.php` | `src/layouts/` | Page wrappers |
| `style.css` | `src/styles/` | Global CSS |
| `functions.php` | `astro.config.mjs` | Site configuration |
A typical Astro project:
```
src/
├── components/ # Reusable UI (Header, PostCard, etc.)
├── layouts/ # Page shells (Base.astro)
├── pages/ # Routes - files become URLs
│ ├── index.astro # → /
│ ├── posts/
│ │ ├── index.astro # → /posts
│ │ └── [slug].astro # → /posts/hello-world
│ └── [slug].astro # → /about, /contact, etc.
└── styles/
└── global.css
```
## Astro Components
`.astro` files are Astro's equivalent of PHP templates. Each file has two parts:
1. **Frontmatter** (between `---` fences) — Server-side code, like PHP at the top of a template
2. **Template** — HTML with expressions, like the rest of a PHP template
```astro title="src/components/PostCard.astro"
---
// Frontmatter: runs on server, never sent to browser
interface Props {
title: string;
excerpt: string;
url: string;
}
const { title, excerpt, url } = Astro.props;
---
<!-- Template: outputs HTML -->
<article class="post-card">
<h2><a href={url}>{title}</a></h2>
<p>{excerpt}</p>
</article>
```
Key differences from PHP:
- **Frontmatter is isolated.** Variables declared there are available in the template, but the code itself never reaches the browser.
- **Imports go in frontmatter.** Components, data, utilities—all imported at the top.
- **TypeScript works.** Define prop types with `interface Props` for editor autocomplete and validation.
## Template Expressions
Astro templates use `{curly braces}` instead of `<?php ?>` tags. The syntax is JSX-like but outputs pure HTML.
<Tabs>
<TabItem label="Astro">
```astro title="src/components/PostList.astro"
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
const showTitle = true;
---
{showTitle && <h1>Latest Posts</h1>}
{posts.length > 0 ? (
<ul>
{posts.map(post => (
<li>
<a href={`/posts/${post.id}`}>{post.data.title}</a>
</li>
))}
</ul>
) : (
<p>No posts found.</p>
)}
```
</TabItem>
<TabItem label="PHP">
```php title="template-parts/post-list.php"
<?php
$posts = new WP_Query(['post_type' => 'post']);
$show_title = true;
?>
<?php if ($show_title): ?>
<h1>Latest Posts</h1>
<?php endif; ?>
<?php if ($posts->have_posts()): ?>
<ul>
<?php while ($posts->have_posts()): $posts->the_post(); ?>
<li>
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
</li>
<?php endwhile; wp_reset_postdata(); ?>
</ul>
<?php else: ?>
<p>No posts found.</p>
<?php endif; ?>
```
</TabItem>
</Tabs>
### Expression Patterns
| Pattern | Purpose |
| -------------------------------------- | --------------------- |
| `{variable}` | Output a value |
| `{condition && <Element />}` | Conditional rendering |
| `{condition ? <A /> : <B />}` | If/else |
| `{items.map(item => <Li>{item}</Li>)}` | Loops |
<Aside>
Unlike PHP, you don't need to escape output. Astro escapes expressions by default, preventing XSS
vulnerabilities.
</Aside>
## Props and Slots
Components receive data through **props** (like function arguments) and **slots** (like `do_action` insertion points).
<Tabs>
<TabItem label="Astro">
```astro title="src/components/Card.astro"
---
interface Props {
title: string;
featured?: boolean;
}
const { title, featured = false } = Astro.props;
---
<article class:list={["card", { featured }]}>
<h2>{title}</h2>
<slot />
<slot name="footer" />
</article>
```
Usage:
```astro
<Card title="Hello" featured>
<p>This goes in the default slot.</p>
<footer slot="footer">Footer content</footer>
</Card>
```
</TabItem>
<TabItem label="PHP">
```php title="template-parts/card.php"
<?php
// Usage: get_template_part('template-parts/card', null, [
// 'title' => 'Hello',
// 'featured' => true
// ]);
$title = $args['title'] ?? '';
$featured = $args['featured'] ?? false;
$class = $featured ? 'card featured' : 'card';
?>
<article class="<?php echo esc_attr($class); ?>">
<h2><?php echo esc_html($title); ?></h2>
<?php
// No direct equivalent to slots.
// WordPress uses do_action() for similar patterns:
do_action('card_content');
do_action('card_footer');
?>
</article>
```
</TabItem>
</Tabs>
### Props vs `$args`
In WordPress, `get_template_part()` passes data via the `$args` array. Astro props are typed and destructured:
```astro
---
// Type-safe with defaults
interface Props {
title: string;
count?: number;
}
const { title, count = 10 } = Astro.props;
---
```
### Slots vs Hooks
WordPress uses `do_action()` to create insertion points. Astro uses slots:
| WordPress | Astro |
| ----------------------------- | ------------------------ |
| `do_action('before_content')` | `<slot name="before" />` |
| Default content area | `<slot />` |
| `do_action('after_content')` | `<slot name="after" />` |
The difference: slots receive child elements at the call site, while WordPress hooks require separate `add_action()` calls elsewhere.
## Layouts
Layouts wrap pages with common HTML structure—the `<head>`, header, footer, and anything shared across pages. This replaces `header.php` + `footer.php`.
```astro title="src/layouts/Base.astro"
---
import "../styles/global.css";
interface Props {
title: string;
description?: string;
}
const { title, description = "My EmDash Site" } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content={description} />
<title>{title}</title>
</head>
<body>
<header>
<nav><!-- Navigation --></nav>
</header>
<main>
<slot />
</main>
<footer>
<p>&copy; {new Date().getFullYear()}</p>
</footer>
</body>
</html>
```
Use the layout in a page:
```astro title="src/pages/index.astro"
---
import Base from "../layouts/Base.astro";
---
<Base title="Home">
<h1>Welcome</h1>
<p>Page content goes in the slot.</p>
</Base>
```
<Aside type="tip">
Unlike `get_header()` and `get_footer()`, layouts keep the entire HTML structure in one file. This
makes it easier to see the full page structure and pass data between sections.
</Aside>
## Styling
Astro offers several styling approaches. The most distinctive is **scoped styles**.
### Scoped Styles
Styles in a `<style>` tag are automatically scoped to that component:
```astro title="src/components/Card.astro"
<article class="card">
<h2>Title</h2>
</article>
<style>
/* Only affects .card in THIS component */
.card {
padding: 1rem;
border: 1px solid #ddd;
}
h2 {
color: navy;
}
</style>
```
The generated HTML includes unique class names to prevent style leakage. No more specificity wars.
### Global Styles
For site-wide styles, create a CSS file and import it in a layout:
```astro title="src/layouts/Base.astro"
---
import "../styles/global.css";
---
```
### Conditional Classes
The `class:list` directive replaces manual class string building:
<Tabs>
<TabItem label="Astro">
```astro
---
const { featured, size = "medium" } = Astro.props;
---
<article class:list={[
"card",
size,
{ featured, "has-border": true }
]}>
```
Output: `<article class="card medium featured has-border">`
</TabItem>
<TabItem label="PHP">
```php
<?php
$classes = ['card', $size];
if ($featured) $classes[] = 'featured';
if (true) $classes[] = 'has-border';
?>
<article class="<?php echo esc_attr(implode(' ', $classes)); ?>">
```
</TabItem>
</Tabs>
## Client-Side JavaScript
Astro ships zero JavaScript by default. This is the biggest mental shift from WordPress.
### Adding Interactivity
For simple interactions, add a `<script>` tag:
```astro title="src/components/MobileMenu.astro"
<button id="menu-toggle">Menu</button>
<nav id="mobile-menu" hidden>
<slot />
</nav>
<script>
const toggle = document.getElementById("menu-toggle");
const menu = document.getElementById("mobile-menu");
toggle?.addEventListener("click", () => {
menu?.toggleAttribute("hidden");
});
</script>
```
Scripts are bundled and deduplicated automatically. If this component appears twice on a page, the script runs once.
### Advanced: Interactive Components
For more complex interactivity, Astro can load JavaScript components (React, Vue, Svelte) on demand. This is optional—most sites work fine with just `<script>` tags.
```astro title="src/pages/index.astro"
---
import SearchWidget from "../components/SearchWidget.jsx";
---
<!-- Only load JavaScript when the search box scrolls into view -->
<SearchWidget client:visible />
```
| Directive | When JavaScript loads |
| ---------------- | ------------------------------ |
| `client:load` | Immediately on page load |
| `client:visible` | When component enters viewport |
| `client:idle` | When browser is idle |
<Aside type="tip">
This is entirely optional. You can build full EmDash sites without touching React, Vue, or any JavaScript framework. The `<script>` tag approach handles most interactive needs.
</Aside>
## Routing
Astro uses **file-based routing**. Files in `src/pages/` become URLs:
| File | URL |
| ------------------------------ | -------------------- |
| `src/pages/index.astro` | `/` |
| `src/pages/about.astro` | `/about` |
| `src/pages/posts/index.astro` | `/posts` |
| `src/pages/posts/[slug].astro` | `/posts/hello-world` |
| `src/pages/[...slug].astro` | Any path (catch-all) |
### Dynamic Routes
For CMS content, use bracket syntax for dynamic segments:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
import Base from "../../layouts/Base.astro";
import { PortableText } from "emdash/ui";
// For static builds, define which pages to generate
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts");
return posts.map(post => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
</Base>
```
### Compared to WordPress
| WordPress | Astro |
| -------------------------------------- | ----------------------------------- |
| Template hierarchy (`single-post.php`) | Explicit file: `posts/[slug].astro` |
| Rewrite rules + `query_vars` | File structure |
| `$wp_query` determines template | URL maps directly to file |
| `add_rewrite_rule()` | Create files or folders |
<Aside type="tip">
No more guessing which template WordPress will use. The URL `/posts/hello` always loads
`src/pages/posts/[slug].astro`.
</Aside>
## Where WordPress Concepts Live
A reference for finding the Astro/EmDash equivalent of WordPress features:
### Templating
| WordPress | Astro/EmDash |
| ------------------------ | ---------------------------------- |
| Template hierarchy | File-based routing in `src/pages/` |
| `get_template_part()` | Import and use components |
| `the_content()` | `<PortableText value={content} />` |
| `the_title()`, `the_*()` | Access via `post.data.title` |
| Template tags | Template expressions `{value}` |
| `body_class()` | `class:list` directive |
### Data and Queries
| WordPress | Astro/EmDash |
| ----------------- | -------------------------------------- |
| `WP_Query` | `getEmDashCollection(type, filters)` |
| `get_post()` | `getEmDashEntry(type, id)` |
| `get_posts()` | `getEmDashCollection(type)` |
| `get_the_terms()` | Access via `entry.data.categories` |
| `get_post_meta()` | Access via `entry.data.fieldName` |
| `get_option()` | `getSiteSettings()` |
| `wp_nav_menu()` | `getMenu(location)` |
### Extensibility
| WordPress | Astro/EmDash |
| ----------------------- | ------------------------------------- |
| `add_action()` | EmDash hooks, Astro middleware |
| `add_filter()` | EmDash hooks |
| `add_shortcode()` | Portable Text custom blocks |
| `register_block_type()` | Portable Text custom blocks |
| `register_sidebar()` | EmDash widget areas |
| Plugins | Astro integrations + EmDash plugins |
### Content Types
| WordPress | Astro/EmDash |
| ---------------------- | ------------------------------------- |
| `register_post_type()` | Create collection in admin UI |
| `register_taxonomy()` | Create taxonomy in admin UI |
| `register_meta()` | Add field to collection schema |
| Post status | Entry status (draft, published, etc.) |
| Featured image | Media reference field |
| Gutenberg blocks | Portable Text blocks |
## Summary
The jump from WordPress to Astro is significant but logical:
1. **PHP templates → Astro components** — Same idea (server code + HTML), better organization
2. **Template tags → Props and imports** — Explicit data flow instead of globals
3. **Theme files → Pages directory** — URLs match file structure
4. **Hooks → Slots and middleware** — More predictable insertion points
5. **jQuery by default → Zero JS by default** — Add interactivity intentionally
Start with the [Getting Started](/getting-started/) guide to build your first EmDash site, or explore [Working with Content](/guides/working-with-content/) to learn how to query and render CMS data.

View File

@@ -0,0 +1,387 @@
---
title: EmDash for Astro Developers
description: Add WordPress-style CMS features to your Astro site with EmDash
---
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash is a CMS built specifically for Astro—not a generic headless CMS with an Astro adapter. It extends your Astro site with database-backed content, a polished admin UI, and WordPress-style features (menus, widgets, taxonomies) while preserving the developer experience you expect.
Everything you know about Astro still applies. EmDash enhances your site; it doesn't replace your workflow.
## What EmDash Adds
EmDash provides the content management features that file-based Astro sites lack:
| Feature | Description |
| -------------------- | ---------------------------------------------------- |
| **Admin UI** | Full WYSIWYG editing interface at `/_emdash/admin` |
| **Database storage** | Content stored in SQLite, libSQL, or Cloudflare D1 |
| **Media library** | Upload, organize, and serve images and files |
| **Navigation menus** | Drag-and-drop menu management with nesting |
| **Widget areas** | Dynamic sidebars and footer regions |
| **Site settings** | Global configuration (title, logo, social links) |
| **Taxonomies** | Categories, tags, and custom taxonomies |
| **Preview system** | Signed preview URLs for draft content |
| **Revisions** | Content version history |
<Aside type="tip">
Content changes appear immediately without rebuilding your site. EmDash sites run in SSR mode by
default.
</Aside>
## Astro Collections vs EmDash
Astro's `astro:content` collections are file-based and resolved at build time. EmDash collections are database-backed and resolved at runtime.
| | Astro Collections | EmDash Collections |
| ------------------ | ------------------------------------ | ------------------------------------ |
| **Storage** | Markdown/MDX files in `src/content/` | SQLite/D1 database |
| **Editing** | Code editor | Admin UI |
| **Content format** | Markdown with frontmatter | Portable Text (structured JSON) |
| **Updates** | Requires rebuild | Instant (SSR) |
| **Schema** | Zod in `content.config.ts` | Defined in admin, stored in database |
| **Best for** | Developer-managed content | Editor-managed content |
### Use Both Together
Astro collections and EmDash can coexist. Use Astro collections for developer content (docs, changelogs) and EmDash for editor content (blog posts, pages):
```astro title="src/pages/index.astro"
---
import { getCollection } from "astro:content";
import { getEmDashCollection } from "emdash";
// Developer-managed docs from files
const docs = await getCollection("docs");
// Editor-managed posts from database
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 5,
});
---
```
## Configuration
EmDash requires two configuration files.
### Astro Integration
```ts title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server", // Required for EmDash
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
### Live Collections Loader
```ts title="src/live.config.ts"
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({
loader: emdashLoader(),
}),
};
```
This registers EmDash as a live content source. The `_emdash` collection internally routes to your content types (posts, pages, products).
## Querying Content
EmDash provides query functions that follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning `{ entries, error }` or `{ entry, error }`:
<Tabs>
<TabItem label="EmDash">
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
// Get all published posts - returns { entries, error }
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Get a single post by slug - returns { entry, error, isPreview }
const { entry: post } = await getEmDashEntry("posts", "my-post");
````
</TabItem>
<TabItem label="Astro">
```ts
import { getCollection, getEntry } from "astro:content";
// Get all blog entries
const posts = await getCollection("blog");
// Get a single entry by slug
const post = await getEntry("blog", "my-post");
````
</TabItem>
</Tabs>
### Filtering Options
`getEmDashCollection` supports filtering that Astro's `getCollection` doesn't:
```ts
const { entries: posts } = await getEmDashCollection("posts", {
status: "published", // draft | published | archived
limit: 10, // max results
where: { category: "news" }, // taxonomy filter
});
```
## Rendering Content
EmDash stores rich text as Portable Text, a structured JSON format. Render it with the `PortableText` component:
<Tabs>
<TabItem label="EmDash">
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
---
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
```
</TabItem>
<TabItem label="Astro">
```astro title="src/pages/blog/[slug].astro"
---
import { getEntry, render } from "astro:content";
const { slug } = Astro.params;
const post = await getEntry("blog", slug);
const { Content } = await render(post);
---
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
```
</TabItem>
</Tabs>
<Aside>
Portable Text preserves content structure without embedding HTML. This makes content portable
across renderers and prevents XSS vulnerabilities.
</Aside>
## Dynamic Features
EmDash provides APIs for WordPress-style features that don't exist in Astro's content layer.
### Navigation Menus
```astro title="src/layouts/Base.astro"
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
{primaryMenu && (
<nav>
<ul>
{primaryMenu.items.map(item => (
<li>
<a href={item.url}>{item.label}</a>
{item.children.length > 0 && (
<ul>
{item.children.map(child => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}
```
### Widget Areas
```astro title="src/layouts/BlogPost.astro"
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar && sidebar.widgets.length > 0 && (
<aside>
{sidebar.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && widget.content && (
<PortableText value={widget.content} />
)}
</div>
))}
</aside>
)}
```
### Site Settings
```astro title="src/components/Header.astro"
---
import { getSiteSettings, getSiteSetting } from "emdash";
const settings = await getSiteSettings();
// Or fetch individual values:
const title = await getSiteSetting("title");
---
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
{settings.tagline && <p>{settings.tagline}</p>}
</header>
```
## Plugins
Extend EmDash with plugins that add hooks, storage, settings, and admin UI:
```ts title="astro.config.mjs"
import emdash from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";
export default defineConfig({
integrations: [
emdash({
// ...
plugins: [seoPlugin({ generateSitemap: true })],
}),
],
});
```
Create custom plugins with `definePlugin`:
```ts title="src/plugins/analytics.ts"
import { definePlugin } from "emdash";
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["content:read"],
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
admin: {
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
},
},
});
```
## Server Rendering
EmDash sites run in SSR mode. Content changes appear immediately without rebuilds.
For static pages with `getStaticPaths`, content is fetched at build time:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
---
```
For dynamic pages, set `prerender = false` to fetch content on each request:
```astro title="src/pages/posts/[slug].astro"
---
export const prerender = false;
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return new Response(null, { status: 404 });
}
---
```
<Aside type="tip">
Use server rendering for frequently updated content. Use static generation for content that
changes rarely and benefits from CDN caching.
</Aside>
## Next Steps
<CardGrid>
<Card title="Getting Started" icon="rocket">
[Create your first EmDash site](/getting-started/) in under 5 minutes.
</Card>
<Card title="Querying Content" icon="document">
[Learn the query API](/guides/querying-content/) in detail.
</Card>
<Card title="Create a Blog" icon="pencil">
[Build a complete blog](/guides/create-a-blog/) with categories and tags.
</Card>
<Card title="Deploy to Cloudflare" icon="external">
[Take your site to production](/deployment/cloudflare/) on Workers.
</Card>
</CardGrid>

View File

@@ -0,0 +1,398 @@
---
title: EmDash for WordPress Developers
description: A guide to EmDash's features and concepts for developers familiar with WordPress
---
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash brings familiar WordPress concepts—posts, pages, taxonomies, menus, widgets, and a media library—into a modern Astro stack. Your content management knowledge transfers directly.
## What Stays Familiar
The concepts you know from WordPress are first-class features in EmDash:
- **Collections** work like Custom Post Types—define your content structure, query it in templates
- **Taxonomies** work the same way—hierarchical (like categories) and flat (like tags)
- **Menus** with drag-and-drop ordering and nested items
- **Widget Areas** for sidebars and dynamic content regions
- **Media library** with upload, organization, and image management
- **Admin UI** that content editors can use without touching code
<Aside type="tip">
You don't need to know React or any specific JavaScript framework. Astro components use HTML with
simple template expressions—closer to PHP templates than to React.
</Aside>
## What's Different
The implementation changes, but the mental model stays the same:
<CardGrid>
<Card title="TypeScript instead of PHP" icon="seti:typescript">
Templates are Astro components. The syntax is cleaner, but the concept is the same: server code
that outputs HTML.
</Card>
<Card title="Content APIs instead of WP_Query" icon="document">
Query functions like `getEmDashCollection()` replace `WP_Query`. No SQL, just function calls.
</Card>
<Card title="File-based routing" icon="puzzle">
Files in `src/pages/` become URLs. No rewrite rules or template hierarchy to memorize.
</Card>
<Card title="Components instead of template parts" icon="rocket">
Import and use components. Same idea as `get_template_part()`, better organization.
</Card>
</CardGrid>
## Quick Reference
| WordPress | EmDash | Notes |
| ---------------------- | ------------------------------------ | --------------------------------- |
| Custom Post Types | Collections | Define via admin UI or API |
| `WP_Query` | `getEmDashCollection()` | Filters, limits, taxonomy queries |
| `get_post()` | `getEmDashEntry()` | Returns entry or null |
| Categories/Tags | Taxonomies | Hierarchical support preserved |
| `register_nav_menus()` | `getMenu()` | First-class menu support |
| `register_sidebar()` | `getWidgetArea()` | First-class widget areas |
| `bloginfo('name')` | `getSiteSetting("title")` | Site settings API |
| `the_content()` | `<PortableText />` | Structured content rendering |
| Shortcodes | Portable Text blocks | Custom components |
| `add_action/filter()` | Plugin hooks | `content:beforeSave`, etc. |
| `wp_options` | `ctx.kv` | Key-value storage |
| Theme directory | `src/` directory | Components, layouts, pages |
| `functions.php` | `astro.config.mjs` + EmDash config | Build and runtime config |
## Content APIs
### Querying Collections
WordPress queries use `WP_Query` or helper functions. EmDash uses typed query functions.
<Tabs>
<TabItem label="WordPress">
```php title="archive.php"
<?php
$posts = new WP_Query([
'post_type' => 'post',
'posts_per_page' => 10,
'post_status' => 'publish',
'category_name' => 'news',
]);
while ($posts->have_posts()) :
$posts->the_post();
?>
<h2><?php the_title(); ?></h2>
<?php the_excerpt(); ?>
<?php endwhile; ?>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/pages/posts/index.astro"
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
where: { category: "news" },
});
---
{posts.map((post) => (
<article>
<h2>{post.data.title}</h2>
<p>{post.data.excerpt}</p>
</article>
))}
```
</TabItem>
</Tabs>
### Getting a Single Entry
<Tabs>
<TabItem label="WordPress">
```php title="single.php"
<?php
$post = get_post($id);
?>
<article>
<h1><?php echo $post->post_title; ?></h1>
<?php echo apply_filters('the_content', $post->post_content); ?>
</article>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
## if (!post) return Astro.redirect("/404");
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
```
</TabItem>
</Tabs>
## Template Hierarchy
WordPress uses a template hierarchy to select which file renders a page. Astro uses explicit file-based routing.
| WordPress Template | EmDash Equivalent |
| --------------------------- | ----------------------------------- |
| `index.php` | `src/pages/index.astro` |
| `single.php` | `src/pages/posts/[slug].astro` |
| `single-{type}.php` | `src/pages/{type}/[slug].astro` |
| `page.php` | `src/pages/pages/[slug].astro` |
| `archive.php` | `src/pages/posts/index.astro` |
| `archive-{type}.php` | `src/pages/{type}/index.astro` |
| `category.php` | `src/pages/categories/[slug].astro` |
| `tag.php` | `src/pages/tags/[slug].astro` |
| `search.php` | `src/pages/search.astro` |
| `404.php` | `src/pages/404.astro` |
| `header.php` / `footer.php` | `src/layouts/Base.astro` |
| `sidebar.php` | `src/components/Sidebar.astro` |
<Aside type="tip">
Astro's routing is more explicit than WordPress's hierarchy. Each route is a file. Dynamic
segments use `[param]` syntax.
</Aside>
## Template Parts → Components
WordPress template parts become Astro components:
<Tabs>
<TabItem label="WordPress">
```php title="functions.php / template"
// In template:
get_template_part('template-parts/content', 'post');
// template-parts/content-post.php:
<article class="post">
<h2><?php the_title(); ?></h2>
<?php the_excerpt(); ?>
</article>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/components/PostCard.astro"
---
const { post } = Astro.props;
---
<article class="post">
<h2>{post.data.title}</h2>
<p>{post.data.excerpt}</p>
</article>
```
```astro title="src/pages/index.astro"
---
import PostCard from "../components/PostCard.astro";
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
{posts.map((post) => <PostCard {post} />)}
```
</TabItem>
</Tabs>
## Menus
EmDash has first-class menu support with automatic URL resolution:
<Tabs>
<TabItem label="WordPress">
```php title="header.php"
<?php
wp_nav_menu([
'theme_location' => 'primary',
'container' => 'nav',
]);
?>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/components/Header.astro"
---
import { getMenu } from "emdash";
## const menu = await getMenu("primary");
<nav>
<ul>
{menu?.items.map((item) => (
<li>
<a href={item.url}>{item.label}</a>
</li>
))}
</ul>
</nav>
```
</TabItem>
</Tabs>
Menus are created via the admin UI, seed files, or WordPress import.
## Widget Areas
Widget areas work like sidebars in WordPress:
<Tabs>
<TabItem label="WordPress">
```php title="sidebar.php"
<?php if (is_active_sidebar('sidebar-1')) : ?>
<aside>
<?php dynamic_sidebar('sidebar-1'); ?>
</aside>
<?php endif; ?>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/components/Sidebar.astro"
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
## const sidebar = await getWidgetArea("sidebar");
{sidebar && (
<aside>
{sidebar.widgets.map((widget) => {
if (widget.type === "content") {
return <PortableText value={widget.content} />;
}
// Handle other widget types
})}
</aside>
)}
```
</TabItem>
</Tabs>
## Site Settings
Site options and customizer settings map to `getSiteSetting()`:
| WordPress | EmDash |
| --------------------------- | ------------------------------ |
| `bloginfo('name')` | `getSiteSetting("title")` |
| `bloginfo('description')` | `getSiteSetting("tagline")` |
| `get_custom_logo()` | `getSiteSetting("logo")` |
| `get_option('date_format')` | `getSiteSetting("dateFormat")` |
| `home_url()` | `Astro.site` |
```ts
import { getSiteSetting } from "emdash";
const title = await getSiteSetting("title");
const logo = await getSiteSetting("logo"); // Returns { mediaId, alt, url }
```
## Taxonomies
Taxonomies work the same conceptually—hierarchical (like categories) or flat (like tags):
```ts
import { getTaxonomyTerms, getEntryTerms, getTerm } from "emdash";
// Get all categories
const categories = await getTaxonomyTerms("categories");
// Get a specific term
const news = await getTerm("categories", "news");
// Get terms for a post
const postCategories = await getEntryTerms("posts", postId, "categories");
```
## Hooks → Plugin System
WordPress hooks (`add_action`, `add_filter`) become EmDash plugin hooks:
| WordPress Hook | EmDash Hook | Purpose |
| --------------- | ----------------------- | ---------------------------- |
| `save_post` | `content:beforeSave` | Modify content before saving |
| `the_content` | PortableText components | Transform rendered content |
| `pre_get_posts` | Query options | Filter queries |
| `wp_head` | Layout `<head>` | Add head content |
| `wp_footer` | Layout before `</body>` | Add footer content |
## What's Better in EmDash
<CardGrid>
<Card title="Type Safety" icon="seti:typescript">
TypeScript throughout. Collections, queries, and components are fully typed. No more guessing
field names or return types.
</Card>
<Card title="Performance" icon="rocket">
No PHP overhead. Static generation by default. Server rendering when needed. Edge deployment
ready.
</Card>
<Card title="Modern DX" icon="laptop">
Hot module replacement. Component-based architecture. Modern tooling (Vite, TypeScript, ESLint).
</Card>
<Card title="Git-based Deployments" icon="github">
Code and templates in git. Content in the database. No FTP, no file permissions, no hacked
sites.
</Card>
</CardGrid>
### Preview Links
EmDash generates secure preview URLs with HMAC-signed tokens. Content editors can preview drafts without logging into production—share a link, not credentials.
### No Plugin Conflicts
WordPress plugin conflicts disappear. EmDash plugins run in isolated contexts with explicit APIs. No global state pollution.
## Content Editor Experience
Content editors use the EmDash admin panel, similar to wp-admin:
- **Dashboard** with recent activity
- **Collection listings** with search, filter, and bulk actions
- **Rich editor** for content (Portable Text, not Gutenberg)
- **Media library** with drag-and-drop upload
- **Menu builder** with drag-and-drop ordering
- **Widget area editor** for sidebar content
The editing experience is familiar. The technology underneath is modern.
## Migration Path
EmDash imports WordPress content directly:
1. Export from WordPress (Tools → Export)
2. Upload the `.xml` file in EmDash's admin
3. Map post types to collections
4. Import content and media
Posts, pages, taxonomies, menus, and media transfer. Gutenberg blocks convert to Portable Text. Custom fields are analyzed and mapped.
See the [WordPress Migration Guide](/migration/from-wordpress/) for complete instructions.
## Next Steps
- **[Getting Started](/getting-started/)** — Set up your first EmDash site
- **[Querying Content](/guides/querying-content/)** — Deep dive into content APIs
- **[Taxonomies](/guides/taxonomies/)** — Categories, tags, and custom taxonomies
- **[Menus](/guides/menus/)** — Navigation menus
- **[Migrate from WordPress](/migration/from-wordpress/)** — Import existing content

View File

@@ -0,0 +1,378 @@
---
title: Admin Panel
description: The EmDash admin panel—a React SPA with TanStack Router, TanStack Query, and Kumo components.
---
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
The EmDash admin panel is a React single-page application embedded in your Astro site. It provides a complete content management interface for editors and administrators.
## Architecture Overview
```
┌────────────────────────────────────────────────────────────────┐
│ Astro Shell │
│ /_emdash/admin/[...path].astro │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ React SPA │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
│ │ │ TanStack │ │ TanStack │ │ Kumo │ │ │
│ │ │ Router │ │ Query │ │ Components │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
│ │ │ │
│ │ ┌────────────────────────────────────────────────────┐ │ │
│ │ │ REST API Client │ │ │
│ │ │ /_emdash/api/* │ │ │
│ │ └────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
```
The admin is a "big island" React app. Astro handles the shell and authentication; all navigation and rendering inside the admin is client-side.
## Technology Stack
| Layer | Technology | Purpose |
| ----------- | --------------------- | ---------------------------------------- |
| **Routing** | TanStack Router | Type-safe client-side routing |
| **Data** | TanStack Query | Server state, caching, mutations |
| **UI** | Kumo | Accessible components (Base UI + Tailwind) |
| **Tables** | TanStack Table | Sorting, filtering, pagination |
| **Forms** | React Hook Form + Zod | Validation matching server schema |
| **Icons** | Phosphor | Consistent iconography |
| **Editor** | TipTap | Rich text editing (Portable Text) |
<Aside type="note">
Kumo is Cloudflare's design system, built on Base UI primitives with Tailwind styling. It's
installed as a dependency of the admin package.
</Aside>
## Route Structure
The admin mounts at `/_emdash/admin/` and uses client-side routing:
| Path | Screen |
| -------------------------- | --------------------------- |
| `/` | Dashboard |
| `/content/:collection` | Content list |
| `/content/:collection/:id` | Content editor |
| `/content/:collection/new` | New entry |
| `/media` | Media library |
| `/content-types` | Schema builder (admin only) |
| `/menus` | Navigation menus |
| `/widgets` | Widget areas |
| `/taxonomies` | Category/tag management |
| `/settings` | Site settings |
| `/plugins/:pluginId/*` | Plugin pages |
<Aside type="tip">
The `/content-types` route is only visible to administrators. Editors see only the content they
have permission to manage.
</Aside>
## Manifest-Driven UI
The admin doesn't hardcode knowledge of collections or plugins. Instead, it fetches a manifest from the server:
```
GET /_emdash/api/manifest
```
Response:
```json
{
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"labelSingular": "Post",
"icon": "file-text",
"supports": ["drafts", "revisions", "preview"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" }
]
}
],
"plugins": [
{
"id": "audit-log",
"label": "Audit Log",
"adminPages": [{ "path": "history", "label": "Audit History" }],
"widgets": [{ "id": "recent-activity", "title": "Recent Activity" }]
}
],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"version": "abc123"
}
```
The admin builds its navigation, forms, and editors entirely from this manifest. Benefits:
- **Schema changes appear immediately** — No admin rebuild needed
- **Plugin UI integrates automatically** — Pages and widgets from the manifest
- **Type safety at the boundary** — Zod schemas stay on the server
## Data Flow
<Steps>
1. **Admin SPA loads** — TanStack Router initializes 2. **Fetch manifest** — TanStack Query caches
collection/plugin metadata 3. **Build navigation** — Sidebar generated from manifest 4. **User
navigates** — Client-side routing, no page reload 5. **Fetch data** — TanStack Query requests
content from REST APIs 6. **Render forms** — Field editors generated from manifest field
descriptors 7. **Submit changes** — Mutations via TanStack Query, optimistic updates 8. **Server
validates** — Zod schemas on the server, errors returned as JSON
</Steps>
## REST API Endpoints
The admin communicates exclusively through REST APIs:
### Content APIs
| Method | Endpoint | Purpose |
| -------- | ------------------------------------------ | -------------------- |
| `GET` | `/api/content/:collection` | List entries |
| `POST` | `/api/content/:collection` | Create entry |
| `GET` | `/api/content/:collection/:id` | Get entry |
| `PUT` | `/api/content/:collection/:id` | Update entry |
| `DELETE` | `/api/content/:collection/:id` | Soft delete entry |
| `GET` | `/api/content/:collection/:id/revisions` | List revisions |
| `POST` | `/api/content/:collection/:id/preview-url` | Generate preview URL |
### Schema APIs
| Method | Endpoint | Purpose |
| -------- | --------------------------------------------- | ------------------ |
| `GET` | `/api/schema` | Export full schema |
| `GET` | `/api/schema/collections` | List collections |
| `POST` | `/api/schema/collections` | Create collection |
| `PUT` | `/api/schema/collections/:slug` | Update collection |
| `DELETE` | `/api/schema/collections/:slug` | Delete collection |
| `POST` | `/api/schema/collections/:slug/fields` | Add field |
| `PUT` | `/api/schema/collections/:slug/fields/:field` | Update field |
| `DELETE` | `/api/schema/collections/:slug/fields/:field` | Delete field |
### Media APIs
| Method | Endpoint | Purpose |
| -------- | ------------------------ | ----------------------- |
| `GET` | `/api/media` | List media items |
| `POST` | `/api/media/upload-url` | Get signed upload URL |
| `POST` | `/api/media/:id/confirm` | Confirm upload complete |
| `DELETE` | `/api/media/:id` | Delete media item |
| `GET` | `/api/media/file/:key` | Serve media file |
### Other APIs
| Endpoint | Purpose |
| ---------------------- | ------------------------ |
| `/api/settings` | Site settings (GET/POST) |
| `/api/menus/*` | Navigation menus |
| `/api/widget-areas/*` | Widget management |
| `/api/taxonomies/*` | Taxonomy terms |
| `/api/admin/plugins/*` | Plugin state |
## Pagination
All list endpoints use cursor-based pagination:
```json
{
"items": [...],
"nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
}
```
Fetch the next page:
```
GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9
```
<Aside type="note">
Cursor pagination provides consistent results even when content is added or removed between
requests.
</Aside>
## Plugin Admin UI
Plugins can extend the admin with pages and dashboard widgets. The integration generates a virtual module with static imports:
```ts
// virtual:emdash/plugin-admins (generated)
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
import * as pluginAdmin1 from "@emdash-cms/plugin-analytics/admin";
export const pluginAdmins = {
seo: pluginAdmin0,
analytics: pluginAdmin1,
};
```
### Plugin Pages
Plugin pages mount under `/_emdash/admin/plugins/:pluginId/*`:
```tsx
// @emdash-cms/plugin-seo/src/admin.tsx
export const pages = [
{
path: "settings",
component: SEOSettingsPage,
label: "SEO Settings",
},
];
```
Renders at: `/_emdash/admin/plugins/seo/settings`
### Dashboard Widgets
Plugins can add widgets to the dashboard:
```tsx
export const widgets = [
{
id: "seo-overview",
component: SEOWidget,
title: "SEO Overview",
size: "half", // "full" | "half" | "third"
},
];
```
<Aside type="caution">
Plugins can only mount pages under their own namespace (`/plugins/:pluginId/*`). They cannot
override core admin routes.
</Aside>
## Authentication
The admin shell route enforces authentication via Astro middleware:
```ts
// Simplified middleware logic
export async function onRequest({ request, locals }, next) {
const session = await getSession(request);
if (request.url.includes("/_emdash/admin")) {
if (!session?.user) {
return redirect("/_emdash/admin/login");
}
locals.user = session.user;
}
return next();
}
```
The admin SPA itself doesn't handle login—that's an Astro page that sets a session cookie.
## Role-Based Access
Different roles see different parts of the admin:
| Role | Visible Sections |
| ------------- | ------------------------------------------ |
| **Editor** | Dashboard, assigned collections, media |
| **Admin** | + Content Types, all collections, settings |
| **Developer** | + CLI access, generated types |
The manifest endpoint filters collections and features based on the requesting user's role.
## Content Editor
The content editor generates forms dynamically based on field definitions:
```tsx
// Simplified editor rendering
function ContentEditor({ collection, fields }) {
return (
<form>
{fields.map((field) => (
<FieldWidget
key={field.slug}
type={field.type}
label={field.label}
required={field.required}
options={field.options}
/>
))}
</form>
);
}
```
Each field type has a corresponding widget:
| Field Type | Widget |
| -------------- | ---------------- |
| `string` | Text input |
| `text` | Textarea |
| `number` | Number input |
| `boolean` | Toggle switch |
| `datetime` | Date/time picker |
| `select` | Dropdown |
| `multiSelect` | Multi-select |
| `portableText` | TipTap editor |
| `image` | Media picker |
| `reference` | Entry picker |
## Rich Text Editor
Portable Text fields use TipTap (ProseMirror) for editing:
```
User types → TipTap (ProseMirror JSON) → Save → Portable Text (DB)
Load → Portable Text (DB) → TipTap (ProseMirror JSON) → Display
```
Conversion happens at load/save boundaries via `portableTextToProsemirror()` and `prosemirrorToPortableText()`.
Supported blocks:
- Paragraphs, headings (H1-H6)
- Bullet and numbered lists
- Blockquotes, code blocks
- Images (from media library)
- Links
Unknown blocks from plugins or imports are preserved as read-only placeholders.
## Media Library
The media library provides:
- Grid and list views
- Search and filter by type, date
- Drag-and-drop upload
- Image preview with metadata
- Bulk selection and delete
Uploads use signed URLs for direct client-to-storage upload:
<Steps>
1. **Request upload URL** — `POST /api/media/upload-url` 2. **Upload directly** — Client PUTs file
to signed URL (R2/S3) 3. **Confirm upload** — `POST /api/media/:id/confirm` 4. **Server extracts
metadata** — Dimensions, MIME type, etc.
</Steps>
This approach bypasses Workers body size limits and provides real upload progress.
## Next Steps
<CardGrid>
<Card title="Getting Started" icon="rocket">
[Set up your first EmDash site](/getting-started/).
</Card>
<Card title="Plugin Development" icon="puzzle">
[Build admin UI extensions](/plugins/admin-ui/).
</Card>
<Card title="Architecture" icon="open-book">
Review the full [system architecture](/concepts/architecture/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,264 @@
---
title: Architecture
description: How EmDash works under the hood—database-first schema, Live Collections, plugin system, and admin panel.
---
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
EmDash integrates deeply with Astro to provide a complete CMS experience. This page explains the key architectural decisions and how the pieces fit together.
## High-Level Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ Your Astro Site │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ EmDash Integration │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
│ │ │ APIs │ │ Panel │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Data Layer │ │ │
│ │ │ Database (D1/SQLite) + Storage (R2/S3) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Astro Framework │ │
│ │ Live Collections · Middleware · Sessions │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
EmDash runs as an Astro integration. It injects routes for the admin panel and REST APIs, provides a content loader for Live Collections, and manages database migrations and storage connections.
## Database-First Schema
Unlike traditional CMSs that define schema in code, EmDash stores schema definitions in the database itself. Two system tables track your content structure:
- `_emdash_collections` — Collection metadata (slug, label, features)
- `_emdash_fields` — Field definitions for each collection
When you create a "products" collection with title and price fields via the admin UI, EmDash:
1. Inserts records into `_emdash_collections` and `_emdash_fields`
2. Runs `ALTER TABLE` to create `ec_products` with the appropriate columns
This design enables:
- **Runtime schema modification** — Create and edit content types without code changes or rebuilds
- **Non-developer-friendly setup** — Content editors can design their data model through the UI
- **Real SQL columns** — Proper indexing, foreign keys, and query optimization
<Aside type="tip">
Run `npx emdash types` to generate TypeScript types from the live schema. This gives you type
safety for dynamically-defined collections.
</Aside>
## Per-Collection Tables
Each collection gets its own SQLite table with an `ec_` prefix:
```sql
-- Created when "posts" collection is added
CREATE TABLE ec_posts (
-- System columns (always present)
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft', -- draft, published, scheduled
author_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT, -- Soft delete
version INTEGER DEFAULT 1, -- Optimistic locking
-- Content columns (from your field definitions)
title TEXT NOT NULL,
content JSON, -- Portable Text
excerpt TEXT
);
```
**Why per-collection tables instead of a single content table with JSON?**
- Real SQL columns enable proper indexing and queries
- Foreign keys work correctly
- Schema is self-documenting in the database
- No JSON parsing overhead for field access
- Database tools can inspect schema directly
## Live Collections Integration
EmDash uses Astro 6's Live Collections to serve content at runtime. Content changes are immediately available without static rebuilds.
The `emdashLoader()` implements Astro's `LiveLoader` interface:
```ts
// src/live.config.ts
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```
<Aside type="note">
A single `_emdash` Astro collection wraps all your content types. The loader filters by type
internally when you call `getEmDashCollection("posts")`.
</Aside>
Query content using the provided wrapper functions:
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
// Get all published posts
const { entries: posts } = await getEmDashCollection("posts");
// Get drafts
const { entries: drafts } = await getEmDashCollection("posts", {
status: "draft",
});
// Get a single entry by slug
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
```
## Route Injection
The EmDash integration uses Astro's `injectRoute` API to add admin and API routes:
| Path Pattern | Purpose |
| ------------------------------------- | ------------------------------------- |
| `/_emdash/admin/[...path]` | Admin panel SPA |
| `/_emdash/api/manifest` | Admin manifest (collections, plugins) |
| `/_emdash/api/content/[collection]` | CRUD for content entries |
| `/_emdash/api/media/*` | Media library operations |
| `/_emdash/api/schema/*` | Schema management |
| `/_emdash/api/settings` | Site settings |
| `/_emdash/api/menus/*` | Navigation menus |
| `/_emdash/api/taxonomies/*` | Categories, tags, custom taxonomies |
Routes are injected from the `emdash` package—nothing is copied into your project.
## Data Layer
EmDash uses [Kysely](https://kysely.dev) for type-safe SQL queries across all supported databases:
<CardGrid>
<Card title="SQLite" icon="laptop">
Local development with `sqlite({ url: "file:./data.db" })`
</Card>
<Card title="D1" icon="cloudflare">
Cloudflare's serverless SQL with `d1({ binding: "DB" })`
</Card>
<Card title="libSQL" icon="external">
Remote SQLite with `libsql({ url: "...", authToken: "..." })`
</Card>
</CardGrid>
Database configuration is passed to the integration in `astro.config.mjs`:
```ts
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
import { local } from "emdash/storage";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
## Storage Abstraction
Media files are stored separately from the database. EmDash supports:
- **Local filesystem** — Development and simple deployments
- **Cloudflare R2** — S3-compatible object storage on the edge
- **S3-compatible** — Any S3-compatible object storage
Uploads use signed URLs for direct client-to-storage uploads, bypassing Workers body size limits.
## Plugin Architecture
Plugins extend EmDash through a WordPress-inspired hook system:
- **Content hooks** — `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, `content:afterDelete`
- **Media hooks** — `media:beforeUpload`, `media:afterUpload`
- **Isolated storage** — Each plugin gets namespaced KV access
- **Admin UI extensions** — Dashboard widgets, settings pages, custom field editors
Plugins can run in two modes:
1. **Native** — Full access to the host environment (for first-party plugins)
2. **Sandboxed** — Run in V8 isolates with capability-based permissions (for third-party plugins on Cloudflare)
```ts
// astro.config.mjs
import { seoPlugin } from "@emdash-cms/plugin-seo";
emdash({
plugins: [seoPlugin({ maxTitleLength: 60 })],
});
```
## Request Flow
A typical content request follows this path:
<Steps>
1. **Astro receives request** — Your page component runs 2. **Query content** —
`getEmDashCollection()` calls Astro's `getLiveCollection()` 3. **Loader executes** —
`emdashLoader` queries the appropriate `ec_*` table via Kysely 4. **Data returned** — Entries
are mapped to Astro's entry format with `id`, `slug`, and `data` 5. **Page renders** — Your
component receives the content and renders HTML
</Steps>
For admin requests:
<Steps>
1. **Middleware authenticates** — Validates session token 2. **API route handles request** — CRUD
operations via repositories 3. **Hooks fire** — `beforeCreate`, `afterUpdate`, etc. 4. **Database
updates** — Kysely executes SQL 5. **Response returned** — JSON response to admin SPA
</Steps>
## Virtual Modules
EmDash generates virtual modules at build time to configure the runtime:
| Module | Purpose |
| -------------------------------- | ----------------------------------- |
| `virtual:emdash/config` | Database and storage configuration |
| `virtual:emdash/dialect` | Database dialect factory |
| `virtual:emdash/plugin-admins` | Static imports for plugin admin UIs |
This approach ensures bundlers can properly resolve and tree-shake plugin code.
## Next Steps
<CardGrid>
<Card title="Collections" icon="document">
Learn about [content collections and field types](/concepts/collections/).
</Card>
<Card title="Content Model" icon="open-book">
Understand the [database-first content model](/concepts/content-model/).
</Card>
<Card title="Admin Panel" icon="setting">
Explore the [admin panel architecture](/concepts/admin-panel/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,382 @@
---
title: Collections & Fields
description: Define content types with collections and fields—supported field types, validation, and relationships.
---
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
import contentTypesImg from "../../../assets/screenshots/admin-content-types.png";
Collections are the foundation of EmDash's content model. Each collection represents a content type (posts, pages, products) and contains field definitions that determine the shape of your data.
## Creating Collections
Create collections through the admin panel under **Content Types**. Each collection has:
<img src={contentTypesImg.src} alt="EmDash content types showing Pages, Posts, and custom collections with their features" />
| Property | Description |
| --------------- | ---------------------------------------------------- |
| `slug` | URL-safe identifier (e.g., `posts`, `products`) |
| `label` | Display name (e.g., "Blog Posts") |
| `labelSingular` | Singular form (e.g., "Post") |
| `description` | Optional description for editors |
| `icon` | Lucide icon name for the admin sidebar |
| `supports` | Features like drafts, revisions, preview, scheduling, search, seo |
<Aside type="note">
Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`,
`options`, `audit_logs`.
</Aside>
## Collection Features
When creating a collection, enable the features you need:
| Feature | Description |
| ------------ | ---------------------------------------------- |
| `drafts` | Enable draft/published workflow |
| `revisions` | Track content history with version snapshots |
| `preview` | Generate signed preview URLs for draft content |
| `scheduling` | Schedule content to publish at a future date |
```ts
// Example collection with all features enabled
{
slug: "posts",
label: "Blog Posts",
labelSingular: "Post",
supports: ["drafts", "revisions", "preview", "scheduling"]
}
```
## Field Types
EmDash supports 15 field types that map to SQLite column types:
### Text Fields
<Tabs>
<TabItem label="string">
Short text input. Maps to `TEXT` column.
```ts
{ slug: "title", type: "string", label: "Title" }
```
</TabItem>
<TabItem label="text">
Multi-line textarea. Maps to `TEXT` column.
```ts
{ slug: "excerpt", type: "text", label: "Excerpt" }
```
</TabItem>
<TabItem label="slug">
URL-safe slug field. Maps to `TEXT` column.
```ts
{ slug: "handle", type: "slug", label: "URL Handle" }
```
</TabItem>
</Tabs>
### Rich Content
<Tabs>
<TabItem label="portableText">
Rich text editor (TipTap/ProseMirror). Stored as JSON.
```ts
{ slug: "content", type: "portableText", label: "Content" }
```
Portable Text is a block-based format that preserves structure without embedding HTML.
</TabItem>
<TabItem label="json">
Arbitrary JSON data. Stored as JSON.
```ts
{ slug: "metadata", type: "json", label: "Custom Metadata" }
```
</TabItem>
</Tabs>
### Numbers
<Tabs>
<TabItem label="number">
Decimal numbers. Maps to `REAL` column.
```ts
{ slug: "price", type: "number", label: "Price" }
```
</TabItem>
<TabItem label="integer">
Whole numbers. Maps to `INTEGER` column.
```ts
{ slug: "quantity", type: "integer", label: "Stock Quantity" }
```
</TabItem>
</Tabs>
### Booleans & Dates
<Tabs>
<TabItem label="boolean">
True/false toggle. Maps to `INTEGER` (0/1).
```ts
{ slug: "featured", type: "boolean", label: "Featured Post" }
```
</TabItem>
<TabItem label="datetime">
Date and time picker. Stored as ISO 8601 string.
```ts
{ slug: "eventDate", type: "datetime", label: "Event Date" }
```
</TabItem>
</Tabs>
### Selection
<Tabs>
<TabItem label="select">
Single option from a list. Maps to `TEXT` column.
```ts
{
slug: "status",
type: "select",
label: "Product Status",
validation: {
options: ["active", "discontinued", "coming_soon"]
}
}
```
</TabItem>
<TabItem label="multiSelect">
Multiple options from a list. Stored as JSON array.
```ts
{
slug: "features",
type: "multiSelect",
label: "Product Features",
validation: {
options: ["wireless", "waterproof", "eco-friendly"]
}
}
```
</TabItem>
</Tabs>
### Media & References
<Tabs>
<TabItem label="image">
Image picker from media library. Stores media ID as `TEXT`.
```ts
{ slug: "featuredImage", type: "image", label: "Featured Image" }
```
</TabItem>
<TabItem label="file">
File picker from media library. Stores media ID as `TEXT`.
```ts
{ slug: "attachment", type: "file", label: "PDF Attachment" }
```
</TabItem>
<TabItem label="reference">
Reference to another collection's entry. Stores entry ID as `TEXT`.
```ts
{
slug: "author",
type: "reference",
label: "Author",
options: {
collection: "authors"
}
}
```
</TabItem>
</Tabs>
## Field Properties
Every field supports these properties:
| Property | Type | Description |
| -------------- | ----------- | -------------------------------------------- |
| `slug` | `string` | Column name in the database |
| `label` | `string` | Display label in admin UI |
| `type` | `FieldType` | One of the 15 field types |
| `required` | `boolean` | Whether the field must have a value |
| `unique` | `boolean` | Whether values must be unique across entries |
| `defaultValue` | `unknown` | Default value for new entries |
| `validation` | `object` | Type-specific validation rules |
| `widget` | `string` | Custom widget identifier |
| `options` | `object` | Widget-specific configuration |
| `sortOrder` | `number` | Display order in the editor |
<Aside type="caution">
Some field slugs are reserved and cannot be used: `id`, `slug`, `status`, `author_id`,
`primary_byline_id`, `created_at`, `updated_at`, `published_at`, `scheduled_at`, `deleted_at`,
`version`, `live_revision_id`, `draft_revision_id`.
</Aside>
## Validation Rules
The `validation` object varies by field type:
```ts
interface FieldValidation {
required?: boolean; // All types
min?: number; // number, integer
max?: number; // number, integer
minLength?: number; // string, text
maxLength?: number; // string, text
pattern?: string; // string (regex)
options?: string[]; // select, multiSelect
}
```
Example with validation:
```ts
{
slug: "email",
type: "string",
label: "Email Address",
required: true,
unique: true,
validation: {
pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
}
}
```
## Widget Options
The `options` object configures field-specific UI behavior:
```ts
interface FieldWidgetOptions {
rows?: number; // text (textarea rows)
showPreview?: boolean; // image, file
collection?: string; // reference (target collection)
allowMultiple?: boolean; // reference (multiple refs)
[key: string]: unknown; // Custom widget options
}
```
Example reference field:
```ts
{
slug: "relatedProducts",
type: "reference",
label: "Related Products",
options: {
collection: "products",
allowMultiple: true
}
}
```
## Querying Collections
Use the provided query functions to fetch content. These follow Astro's live collections pattern, returning structured results:
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
// Get all entries - returns { entries, error }
const { entries: posts } = await getEmDashCollection("posts");
// Filter by status
const { entries: drafts } = await getEmDashCollection("posts", {
status: "draft",
});
// Limit results
const { entries: recent } = await getEmDashCollection("posts", {
limit: 5,
});
// Filter by taxonomy
const { entries: newsPosts } = await getEmDashCollection("posts", {
where: { category: "news" },
});
// Get a single entry by slug - returns { entry, error, isPreview }
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
// Handle errors
const { entries, error } = await getEmDashCollection("posts");
if (error) {
console.error("Failed to load posts:", error);
}
```
## Type Generation
Run `npx emdash types` to generate TypeScript types from your schema:
```ts
// .emdash/types.ts (generated)
export interface Post {
title: string;
content: PortableTextBlock[];
excerpt?: string;
featuredImage?: string;
author: string; // reference ID
}
export interface Product {
title: string;
price: number;
description: PortableTextBlock[];
}
```
<Aside type="tip">
Re-run `emdash types` after modifying collections in the admin panel to keep types in sync.
</Aside>
## Database Mapping
Field types map to SQLite column types:
| Field Type | SQLite Type | Notes |
| -------------- | ----------- | --------------------- |
| `string` | `TEXT` | |
| `text` | `TEXT` | |
| `slug` | `TEXT` | |
| `number` | `REAL` | 64-bit floating point |
| `integer` | `INTEGER` | 64-bit signed integer |
| `boolean` | `INTEGER` | 0 or 1 |
| `datetime` | `TEXT` | ISO 8601 format |
| `select` | `TEXT` | |
| `multiSelect` | `JSON` | Array of strings |
| `portableText` | `JSON` | Block array |
| `image` | `TEXT` | Media ID |
| `file` | `TEXT` | Media ID |
| `reference` | `TEXT` | Entry ID |
| `json` | `JSON` | Arbitrary JSON |
## Next Steps
<CardGrid>
<Card title="Content Model" icon="open-book">
Understand the [database-first approach](/concepts/content-model/).
</Card>
<Card title="Taxonomies" icon="list-format">
Organize content with [categories and tags](/guides/taxonomies/).
</Card>
<Card title="Media Library" icon="seti:image">
Manage [images and files](/guides/media-library/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,335 @@
---
title: Content Model
description: EmDash's database-first content model—how schema is stored, modified at runtime, and queried.
---
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
EmDash uses a **database-first content model** where schema definitions live in the database, not in code. This is a fundamental design choice that enables runtime schema modification and non-developer-friendly setup.
## Schema as Data
Traditional CMSs like Strapi or Keystatic require you to define schema in code:
```ts
// Traditional approach - schema in code
const posts = collection({
fields: {
title: text({ required: true }),
content: richText(),
},
});
```
EmDash stores this same information in database tables:
```sql
-- _emdash_collections table
INSERT INTO _emdash_collections (slug, label)
VALUES ('posts', 'Blog Posts');
-- _emdash_fields table
INSERT INTO _emdash_fields (collection_id, slug, type, required)
VALUES
('coll_abc', 'title', 'string', true),
('coll_abc', 'content', 'portableText', false);
```
Both approaches define the same content structure. The difference is where that structure lives and how it can be modified.
## Why Database-First?
<CardGrid>
<Card title="Runtime Modification" icon="pencil">
Create and edit content types without code changes or rebuilds. Non-developers can design their
data model through the admin UI.
</Card>
<Card title="Real SQL Columns" icon="seti:db">
Unlike WordPress's EAV (Entity-Attribute-Value) model, each field gets a real column. Proper
indexing, foreign keys, and query optimization.
</Card>
<Card title="Self-Documenting" icon="document">
Database tools can inspect schema directly. No need to parse code to understand the data model.
</Card>
<Card title="Migration Path" icon="right-arrow">
Export schema as JSON for version control. Import schema in new environments.
</Card>
</CardGrid>
## Schema Tables
Two system tables define your content structure:
### Collections Table
```sql
CREATE TABLE _emdash_collections (
id TEXT PRIMARY KEY,
slug TEXT UNIQUE NOT NULL, -- "posts", "products"
label TEXT NOT NULL, -- "Blog Posts"
label_singular TEXT, -- "Post"
description TEXT,
icon TEXT, -- Lucide icon name
supports JSON, -- ["drafts", "revisions", "preview"]
source TEXT, -- How it was created
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT
);
```
The `source` field tracks how the collection was created:
| Source | Description |
| ------------------ | ---------------------------------- |
| `manual` | Created via admin UI |
| `template:blog` | Created by a template's seed file |
| `import:wordpress` | Imported from WordPress |
| `discovered` | Auto-discovered from existing data |
### Fields Table
```sql
CREATE TABLE _emdash_fields (
id TEXT PRIMARY KEY,
collection_id TEXT REFERENCES _emdash_collections(id),
slug TEXT NOT NULL, -- Column name: "title", "price"
label TEXT NOT NULL, -- Display label
type TEXT NOT NULL, -- Field type
column_type TEXT NOT NULL, -- SQLite type: TEXT, REAL, INTEGER, JSON
required INTEGER DEFAULT 0,
unique_field INTEGER DEFAULT 0,
default_value TEXT, -- JSON-encoded default
validation JSON, -- Validation rules
widget TEXT, -- Custom widget identifier
options JSON, -- Widget options
sort_order INTEGER,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
UNIQUE(collection_id, slug)
);
```
## Content Tables
Each collection gets its own table with the `ec_` prefix. When you create a "products" collection with title and price fields:
```sql
CREATE TABLE ec_products (
-- System columns (always present)
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft',
author_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT, -- Soft delete
version INTEGER DEFAULT 1, -- Optimistic locking
-- Content columns (from field definitions)
title TEXT NOT NULL,
price REAL
);
```
<Aside type="tip">
System columns are automatically added to every content table. You don't define them as
fields—they're always present.
</Aside>
## Runtime Schema Changes
When you add a field via the admin UI, EmDash:
<Steps>
1. Inserts a record into `_emdash_fields` 2. Runs `ALTER TABLE ec_collection ADD COLUMN
column_name TYPE` 3. Regenerates the Zod schema for validation
</Steps>
SQLite supports these `ALTER TABLE` operations at runtime:
| Operation | Supported |
| ------------------ | --------------------------- |
| Add column | Yes |
| Rename column | Yes |
| Drop column | Yes (SQLite 3.35+) |
| Change column type | No (requires table rebuild) |
For type changes, EmDash handles the table rebuild transparently: create new table → copy data → drop old table → rename new table.
## Schema vs. Content Separation
EmDash maintains a clear separation:
| Concern | Location | Tables |
| ------------ | ------------------------ | ------------------------------------------- |
| **Schema** | System tables | `_emdash_collections`, `_emdash_fields` |
| **Content** | Per-collection tables | `ec_posts`, `ec_products`, etc. |
| **Media** | Separate table + storage | `media` table + R2/S3 |
| **Settings** | Options table | `options` with `site:` prefix |
This separation means:
- Schema can be exported without content
- Content can be migrated between schemas
- System tables are never cluttered with user data
## Validation at Runtime
EmDash builds Zod schemas from database field definitions at startup:
```ts
// Simplified example
function buildSchema(fields: Field[]): ZodSchema {
const shape: Record<string, ZodType> = {};
for (const field of fields) {
let zodType = fieldTypeToZod(field.type);
if (field.required) {
zodType = zodType.required();
}
if (field.validation?.min !== undefined) {
zodType = zodType.min(field.validation.min);
}
shape[field.slug] = zodType;
}
return z.object(shape);
}
```
Content is validated against these runtime schemas on every create and update operation.
## TypeScript Integration
Generate TypeScript types from your database schema:
```bash
# Fetch schema from database, generate types
npx emdash types
```
This generates `.emdash/types.ts`:
```ts
// .emdash/types.ts (generated)
export interface Post {
title: string;
content: PortableTextBlock[];
excerpt?: string;
featuredImage?: string;
}
export interface Product {
title: string;
price: number;
quantity: number;
}
// Typed overloads for query functions
declare module "emdash" {
export function getEmDashCollection(
type: "posts",
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
export function getEmDashEntry(
type: "products",
id: string,
): Promise<{ entry: ContentEntry<Product> | null; error?: Error; isPreview: boolean }>;
}
```
<Aside type="note">
Type generation is optional but recommended. It gives you autocomplete and type checking for your
dynamically-defined collections.
</Aside>
## Developer vs. Non-Developer Workflow
**Developers** can use the CLI:
```bash
# Fetch schema, generate types
npx emdash types
# Export schema as JSON
npx emdash export-seed > seed.json
```
**Non-developers** use the admin UI exclusively:
1. Open **Content Types** in the admin panel
2. Click **Add Collection**
3. Define fields through the visual builder
4. Start creating content immediately
Both approaches modify the same underlying database tables.
## Seed Files
Templates and exports use JSON seed files for portable schema definitions:
```json
{
"version": "1",
"collections": [
{
"slug": "posts",
"label": "Blog Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "preview"],
"fields": [
{ "slug": "title", "type": "string", "required": true },
{ "slug": "content", "type": "portableText" },
{ "slug": "featuredImage", "type": "image" }
]
}
],
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
"menus": [{ "name": "primary", "label": "Primary Navigation" }]
}
```
Apply seed files programmatically:
```ts
import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";
// Validate first
const { valid, errors } = validateSeed(seedData);
// Apply (idempotent - safe to re-run)
await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip", // 'skip' | 'update' | 'error'
});
```
## Comparison with Other Approaches
| Approach | Schema Location | Runtime Modification | Type Safety |
| ------------- | --------------- | ----------------------- | ------------------ |
| **EmDash** | Database | Yes (full) | Generated from DB |
| **WordPress** | PHP code + EAV | Limited (meta fields) | None |
| **Strapi** | Code files | No (rebuild required) | Generated at build |
| **Sanity** | Code files | No (schema must deploy) | Built-in |
| **Directus** | Database | Yes (full) | Generated from DB |
EmDash follows the Directus model: database-first with optional type generation. This provides maximum flexibility while still supporting type-safe development when desired.
## Next Steps
<CardGrid>
<Card title="Collections" icon="document">
Learn about [field types and validation](/concepts/collections/).
</Card>
<Card title="Admin Panel" icon="setting">
Explore the [admin architecture](/concepts/admin-panel/).
</Card>
<Card title="Seeding" icon="open-book">
Set up sites with [seed files](/themes/seed-files/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,115 @@
---
title: Contributing to EmDash
description: How to set up a development environment and contribute to EmDash.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash is a pnpm monorepo. The main package is `packages/core` (published as `emdash`) — it contains the Astro integration, REST API, database layer, schema management, and plugin system. The admin UI lives in `packages/admin`.
For the full contributor reference — repo layout, architecture, code conventions, changeset policy — see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).
## Local Setup
<Steps>
1. **Clone and install**
```bash
git clone https://github.com/emdash-cms/emdash.git
cd emdash
pnpm install
pnpm build # required before first run
```
2. **Start the demo**
```bash
cd demos/simple
pnpm dev
```
The setup wizard runs automatically on first launch — it creates the database, runs migrations, and prompts you to create an admin account.
To populate with sample content: `pnpm seed`
3. **Open the admin**
Visit [http://localhost:4321/_emdash/admin](http://localhost:4321/_emdash/admin)
In dev mode, you can skip passkey auth with the bypass endpoint:
```
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
```
</Steps>
## Development Workflow
### Watch Mode
For iterating on core packages alongside the demo, run two terminals:
```bash
# Terminal 1 — rebuild packages/core on change
cd packages/core && pnpm dev
# Terminal 2 — run the demo
cd demos/simple && pnpm dev
```
### Checks
Run these before committing (from the repo root):
```bash
pnpm typecheck # TypeScript
pnpm lint # full type-aware lint
pnpm format # auto-format (oxfmt, tabs)
```
<Aside type="caution">
Type checking and linting must both pass. Don't commit with known failures.
</Aside>
### Tests
<Tabs>
<TabItem label="All tests">
```bash
pnpm test
```
</TabItem>
<TabItem label="Core only">
```bash
cd packages/core && pnpm test
```
</TabItem>
<TabItem label="Watch mode">
```bash
cd packages/core && pnpm test --watch
```
</TabItem>
<TabItem label="E2E">
```bash
pnpm test:e2e # starts its own server
```
</TabItem>
</Tabs>
Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.
## What We Accept
| Type | Process |
| ---------------- | --------------------------------------------------------------------------------------------- |
| **Bug fixes** | Open a PR directly. Include a failing test. |
| **Docs / typos** | Open a PR directly. |
| **Translations** | Open a PR directly. See [Translating EmDash](/contributing/translating/). |
| **Features** | Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) and wait for a maintainer to approve it. |
| **Refactors** | Open a Discussion first. |
Feature PRs without prior maintainer approval will be closed.
For the full contribution policy, changeset guide, repo layout, and architecture overview, see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).

View File

@@ -0,0 +1,191 @@
---
title: Translating EmDash
description: How to contribute translations for the EmDash admin UI.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash's admin UI is translatable using [Lingui](https://lingui.dev) for message extraction and [Lunaria](https://lunaria.dev) for tracking translation progress. All translations live in PO (gettext) files — one per locale.
## Translation status
See the [translation dashboard](https://i18n.emdashcms.com) for current progress across all locales.
## Who can translate
Translations must come from **native or fluent speakers**. We don't accept machine-generated translations. If you use AI tools to assist, you must review every string by hand and test the result in context (see [Testing your translations](#testing-your-translations) below).
We'd rather have no translation for a string than a bad one. A wrong translation is worse than showing the English fallback — it actively misleads users.
## File structure
Translation catalogs live in `packages/admin/src/locales/`:
```
packages/admin/src/locales/
├── en/
│ └── messages.po # English (source)
├── de/
│ └── messages.po # German
└── ...
```
Each `.po` file contains `msgid`/`msgstr` pairs. The `msgid` is the English source text; the `msgstr` is your translation. Empty `msgstr` means "not yet translated" — Lingui will fall back to English at runtime.
## Translating strings
<Steps>
1. **Check the [translation dashboard](https://i18n.emdashcms.com)** to see what needs work. Check open PRs to avoid duplicating effort.
2. **Fork the repo and create a branch:**
```bash
git checkout -b i18n/de
```
3. **Open your locale's PO file** (e.g., `packages/admin/src/locales/de/messages.po`).
4. **Fill in translations.** Each entry looks like this:
```po
#: packages/admin/src/components/LoginPage.tsx:304
msgid "Sign in with Passkey"
msgstr ""
```
Fill in the `msgstr`:
```po
#: packages/admin/src/components/LoginPage.tsx:304
msgid "Sign in with Passkey"
msgstr "Mit Passkey anmelden"
```
5. **Test your translations** (see below).
6. **Open a PR** targeting `main`. Title format: `i18n(de): add/update German translations`.
</Steps>
### What to translate
- The `msgstr` value for each entry.
### What NOT to translate
- `msgid` values — these are lookup keys.
- Interpolation placeholders like `{error}`, `{email}`, `{label}` — keep them exactly as-is.
- XML-style tags like `<0>`, `</0>` — these wrap interactive elements (links, buttons). Keep the tags and translate the text between them.
- Comments starting with `#:` — these are source references added by Lingui.
### Interpolation and tags
Some strings contain placeholders and tags:
```po
msgid "Authentication error: {error}"
msgstr "Authentifizierungsfehler: {error}"
msgid "Don't have an account? <0>Sign up</0>"
msgstr "Noch kein Konto? <0>Registrieren</0>"
msgid "If an account exists for <0>{email}</0>, we've sent a sign-in link."
msgstr "Falls ein Konto für <0>{email}</0> existiert, haben wir einen Anmeldelink gesendet."
```
Placeholders (`{error}`, `{email}`) are replaced with dynamic values at runtime. Tags (`<0>...</0>`) wrap React components. Both must appear in your translation exactly as they appear in the source — same names, same nesting.
## Testing your translations
<Aside type="caution">
Always test your translations in the running admin UI. Strings that read well in isolation can look wrong in context — truncated buttons, broken layouts, or awkward phrasing that only shows up when you see the actual screen.
</Aside>
<Steps>
1. **Compile and run the demo:**
```bash
pnpm run locale:compile
pnpm build
pnpm --filter emdash-demo dev
```
2. **Switch locale** in the admin Settings page and verify your translations look correct in context.
</Steps>
### Pseudo locale
EmDash ships a **pseudo locale** that garbles all wrapped strings into accented lookalikes — `"Dashboard"` becomes `"Ðàšĥƀöàřð"`, and so on. Any string that appears in normal English while the pseudo locale is active is either missing a `t\`...\`` wrapper or is coming from outside the catalog.
To enable it, add the following to your `.env` file in the demo directory:
```ini title="demos/simple/.env"
EMDASH_PSEUDO_LOCALE=1
```
Then restart the dev server. The pseudo locale appears as **Pseudo** in the language picker on the login page and in Settings. Switch to it to spot unwrapped strings at a glance.
<Aside>
The pseudo locale is only available in development (`import.meta.env.DEV`). It is never exposed in production builds regardless of environment variables.
</Aside>
## Adding a new language
If your language doesn't have a PO file yet:
<Steps>
1. **Add the locale to `packages/admin/src/locales/locales.ts`:**
```ts
export const LOCALES: LocaleDefinition[] = [
{ code: "en", label: "English", enabled: true },
{ code: "de", label: "Deutsch", enabled: true },
// ...
{ code: "ja", label: "日本語", enabled: false }, // add yours
];
```
This is the single source of truth — `lingui.config.ts`, `lunaria.config.ts` and the admin runtime all derive their locale lists from this file. Set `enabled: false` unless your translations have 100% coverage — we'll enable it once the translation reaches sufficient coverage.
2. **Run extraction** to generate the empty PO file:
```bash
pnpm run locale:extract
```
This creates `packages/admin/src/locales/{your-locale}/messages.po` with all strings ready to translate.
3. **Translate and test** following the steps above.
</Steps>
## Translation standards
### Accuracy
Translations should faithfully represent the English source text at a native speaker's level. Don't add, remove, or reinterpret meaning. If a source string is ambiguous, check the `#:` comment for the source file location — read the component code to understand the context.
### Consistency
Use consistent terminology within your locale. If you translate "collection" as "Sammlung" in one place, don't switch to "Kollektion" elsewhere. If your language already has translations, read through the existing PO file before starting to match the established terminology.
### Tone
The admin UI uses a direct, professional tone. Match that in your language — avoid overly formal or overly casual phrasing.
### AI-assisted translations
You may use AI tools to draft translations, but:
- You **must** review every string yourself. AI tools make subtle errors that only a fluent speaker would catch — wrong register, unnatural phrasing, incorrect technical terms.
- You **must** test the result in the running admin UI. AI tools have no awareness of layout constraints or UI context.
- Disclose AI usage in your PR description.
- PRs with obvious unreviewed machine translations will be closed.
## Partial translations
Partial translations are welcome. You don't need to translate every string in one PR — any progress helps. Untranslated strings will fall back to English at runtime.

View File

@@ -0,0 +1,278 @@
---
title: Deploy to Cloudflare
description: Deploy EmDash to Cloudflare Workers with D1 and R2.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Cloudflare Workers provides a fast, globally distributed runtime for EmDash. This guide covers deploying with D1 for the database and R2 for media storage.
## Prerequisites
- A Cloudflare account
- Wrangler CLI installed (`npm install -g wrangler`)
- Authenticated with Cloudflare (`wrangler login`)
## Create Resources
### 1. Create a D1 database
```bash
wrangler d1 create emdash-db
```
Note the `database_id` from the output.
### 2. Create an R2 bucket
```bash
wrangler r2 bucket create emdash-media
```
### 3. Create wrangler.jsonc
Create `wrangler.jsonc` in your project root:
```jsonc title="wrangler.jsonc"
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-emdash-site",
"compatibility_date": "2025-01-15",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db",
"database_id": "your-database-id",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media",
},
],
}
```
## Configure EmDash
Update your Astro configuration to use D1 and R2:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, r2 } from "@emdash-cms/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
}),
],
});
```
<Aside type="caution">
D1 migrations must be run via Wrangler CLI before deployment. DDL statements are not allowed at
runtime.
</Aside>
## Run Migrations
Generate and apply the database schema.
### 1. Export the schema SQL
```bash
npx emdash init --database ./data.db
```
### 2. Apply migrations to D1
```bash
wrangler d1 migrations apply emdash-db
```
If you don't have migration files, apply the core schema directly:
```bash
wrangler d1 execute emdash-db --file=./node_modules/emdash/migrations/0001_core.sql
```
## Deploy
Deploy to Cloudflare Workers:
```bash
wrangler deploy
```
Your site is now live at `https://my-emdash-site.<your-subdomain>.workers.dev`.
## Read Replicas
For globally distributed sites, enable D1 read replication to route read queries to nearby replicas instead of always hitting the primary database. This significantly reduces latency for visitors far from the primary region.
```js title="astro.config.mjs"
emdash({
database: d1({
binding: "DB",
session: "auto",
}),
storage: r2({ binding: "MEDIA" }),
}),
```
You also need to enable read replication on the D1 database itself in the Cloudflare dashboard or via the REST API.
See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.
## Custom Domain
Add a custom domain in the Cloudflare dashboard:
1. Go to **Workers & Pages** > your worker
2. Click **Custom Domains** > **Add Custom Domain**
3. Enter your domain and follow the DNS setup instructions
## Public R2 Access
To serve media directly from R2 (recommended for performance):
1. In the Cloudflare dashboard, go to **R2** > your bucket
2. Click **Settings** > **Public access**
3. Enable public access and note the public URL
4. Update your storage config:
```js title="astro.config.mjs"
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxx.r2.dev"
}),
```
<Aside type="tip">
For production, connect a custom domain to your R2 bucket for better URLs and caching control.
</Aside>
## Cloudflare Access Authentication
If your organization uses Cloudflare Access, you can use it as your authentication provider instead of passkeys. This provides SSO with your existing identity provider.
```js title="astro.config.mjs"
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
auth: access({
teamDomain: "myteam.cloudflareaccess.com",
audience: "your-app-audience-tag",
roleMapping: {
"Admins": 50,
"Editors": 40,
},
}),
}),
```
See the [Authentication guide](/guides/authentication#cloudflare-access) for full configuration options.
## Environment Variables
### Recommended: encryption key
`EMDASH_ENCRYPTION_KEY` is used to encrypt plugin secrets at rest
(webhook tokens, Turnstile keys, etc.). The current release validates
the key but does not yet use it — plugin secret encryption is rolling
out and will start using this value automatically when available. Set
it now so your deployment is ready.
The key is provided by you and never stored in the database; only
encrypted ciphertext is. Losing it means losing every secret encrypted
with it.
```bash
npx emdash secrets generate
wrangler secret put EMDASH_ENCRYPTION_KEY
```
<Aside type="caution">
Never commit the encryption key to your repository, and back it up
somewhere durable (a password manager, KMS, or your team's secret
store). The plain text value lives only in your environment.
</Aside>
### Optional: stable-value overrides
EmDash auto-generates the preview HMAC secret and commenter-IP hash
salt and persists them in the database on first use. The env vars
below are overrides for cases where you need to pin the value
yourself — for example, when a preview Worker in a separate process
needs to share the secret with your main site.
| Variable | Purpose |
| -------- | ------- |
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
| `EMDASH_AUTH_SECRET` | Legacy. No longer required. If set, it's used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which wins) so existing installs keep stable commenter-IP hashes across the upgrade. New installs should ignore this. |
Access environment variables in your configuration using `import.meta.env` or the Cloudflare `env` binding.
## Preview Deployments
Deploy a preview branch:
```bash
wrangler deploy --env preview
```
Add an environment section to `wrangler.jsonc`:
```jsonc
{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db-preview",
"database_id": "your-preview-db-id",
},
],
},
},
}
```
## Troubleshooting
### "D1 binding not found"
Verify the binding name in `wrangler.jsonc` matches your database configuration:
```js
// Must match: d1({ binding: "DB" })
"binding": "DB"
```
### "R2 binding not found"
Check that the R2 bucket is correctly bound:
```js
// Must match: r2({ binding: "MEDIA" })
"binding": "MEDIA"
```
### Migration errors
D1 migrations run via Wrangler, not at runtime. If you see schema errors:
1. Check that migrations were applied: `wrangler d1 migrations list emdash-db`
2. Re-apply if needed: `wrangler d1 migrations apply emdash-db`

View File

@@ -0,0 +1,320 @@
---
title: Database Options
description: Configure EmDash with D1, PostgreSQL, libSQL, or SQLite.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash supports multiple database backends. Choose based on your deployment target.
## Overview
| Database | Best For | Deployment |
| -------------- | ------------------ | -------------------------- |
| **D1** | Cloudflare Workers | Edge, globally distributed |
| **PostgreSQL** | Production Node.js | Any platform with Postgres |
| **libSQL** | Remote databases | Edge or Node.js |
| **SQLite** | Node.js, local dev | Single server |
## Cloudflare D1
D1 is Cloudflare's serverless SQLite database. Use it when deploying to Cloudflare Workers.
```js title="astro.config.mjs"
import { d1 } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
database: d1({ binding: "DB" }),
}),
],
});
```
### Configuration
| Option | Type | Default | Description |
| ---------------- | -------- | -------------------- | ------------------------------------- |
| `binding` | `string` | — | D1 binding name from `wrangler.jsonc` |
| `session` | `string` | `"disabled"` | Read replication mode (see below) |
| `bookmarkCookie` | `string` | `"__em_d1_bookmark"` | Cookie name for session bookmarks |
### Setup
<Tabs>
<TabItem label="wrangler.jsonc">
```jsonc
{
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db",
"database_id": "your-database-id"
}
]
}
```
</TabItem>
<TabItem label="wrangler.toml">
```toml
[[d1_databases]]
binding = "DB"
database_name = "emdash-db"
database_id = "your-database-id"
```
</TabItem>
</Tabs>
<Aside type="caution">
D1 does not allow DDL statements at runtime. Run migrations via Wrangler CLI before deployment:
```bash wrangler d1 migrations apply emdash-db ```
</Aside>
### Create a D1 Database
```bash
wrangler d1 create emdash-db
```
### Read Replicas
D1 supports [read replication](https://developers.cloudflare.com/d1/configuration/read-replication/) to lower read latency for globally distributed sites. When enabled, read queries are routed to nearby replicas instead of always hitting the primary database.
EmDash uses the D1 Sessions API to manage this transparently. Enable it with the `session` option:
```js title="astro.config.mjs"
import { d1 } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
database: d1({
binding: "DB",
session: "auto",
}),
}),
],
});
```
#### Session Modes
| Mode | Behavior |
| ---------------- | -------------------------------------------------------------------------------------------------- |
| `"disabled"` | No sessions. All queries go to primary. Default. |
| `"auto"` | Anonymous requests read from the nearest replica. Authenticated users get read-your-writes consistency via bookmark cookies. |
| `"primary-first"`| Like `"auto"`, but the first query always goes to the primary. Use for sites with very frequent writes. |
#### How It Works
- **Anonymous visitors** get `first-unconstrained` — reads go to the nearest replica for the lowest latency. Since anonymous users never write, they don't need consistency guarantees.
- **Authenticated users** (editors, authors) get bookmark-based sessions. After a write, a bookmark cookie ensures the next request sees at least that state.
- **Write requests** (`POST`, `PUT`, `DELETE`) always start at the primary database.
- **Build-time queries** (Astro content collections) bypass sessions entirely and use the primary directly.
<Aside type="tip">
Read replication must also be enabled on the D1 database itself via the Cloudflare dashboard or
REST API. The `session` option only controls EmDash's client-side session management.
</Aside>
<Aside>
Anonymous visitors may see data that is a few seconds stale. For a CMS this is expected — content
changes are not real-time, and if you have route caching enabled the response is already stale by
definition.
</Aside>
## libSQL
libSQL is a fork of SQLite that supports remote connections. Use it when you need a remote database without Cloudflare D1.
```js title="astro.config.mjs"
import { libsql } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: libsql({
url: process.env.LIBSQL_DATABASE_URL,
authToken: process.env.LIBSQL_AUTH_TOKEN,
}),
}),
],
});
```
### Configuration
| Option | Type | Description |
| ----------- | -------- | ---------------------------------------------------- |
| `url` | `string` | Database URL (`libsql://...` or `file:...`) |
| `authToken` | `string` | Auth token for remote databases (optional for local) |
### Local Development
Use a local libSQL file during development:
```js
database: libsql({ url: "file:./data.db" });
```
## PostgreSQL
PostgreSQL is supported for Node.js deployments that need a full relational database.
```js title="astro.config.mjs"
import { postgres } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: postgres({
connectionString: process.env.DATABASE_URL,
}),
}),
],
});
```
### Configuration
You can connect with a connection string or individual parameters:
```js
// Connection string
database: postgres({
connectionString: "postgres://user:password@localhost:5432/emdash",
});
// Individual parameters
database: postgres({
host: "localhost",
port: 5432,
database: "emdash",
user: "emdash",
password: process.env.DB_PASSWORD,
ssl: true,
});
```
| Option | Type | Description |
| ------------------ | --------- | ------------------------------------ |
| `connectionString` | `string` | PostgreSQL connection URL |
| `host` | `string` | Database host |
| `port` | `number` | Database port |
| `database` | `string` | Database name |
| `user` | `string` | Database user |
| `password` | `string` | Database password |
| `ssl` | `boolean` | Enable SSL |
| `pool.min` | `number` | Minimum pool connections (default 0) |
| `pool.max` | `number` | Maximum pool connections (default 10)|
### Connection Pooling
The adapter uses `pg.Pool` under the hood. Tune pool size based on your deployment:
```js
database: postgres({
connectionString: process.env.DATABASE_URL,
pool: { min: 2, max: 20 },
});
```
<Aside>
PostgreSQL requires the `pg` package. Install it as a dependency:
```bash
pnpm add pg
```
</Aside>
## SQLite
SQLite with better-sqlite3 is the simplest option for Node.js deployments.
```js title="astro.config.mjs"
import { sqlite } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
}),
],
});
```
### Configuration
| Option | Type | Description |
| ------ | -------- | ----------------------------- |
| `url` | `string` | File path with `file:` prefix |
### File Path
The `url` must start with `file:`:
```js
// Relative path
database: sqlite({ url: "file:./data/emdash.db" });
// Absolute path
database: sqlite({ url: "file:/var/data/emdash.db" });
// From environment variable
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` });
```
<Aside>
SQLite requires a persistent filesystem. It does not work on platforms with ephemeral storage
without additional configuration.
</Aside>
## Migrations
EmDash handles migrations automatically for SQLite, libSQL, and PostgreSQL. For D1, run migrations via Wrangler.
### Check Migration Status
```bash
npx emdash init --database ./data.db
```
This command:
1. Creates the database file if needed
2. Runs any pending migrations
3. Reports the current migration status
### Migration Files
Migrations are bundled with EmDash. To run them manually:
```bash
# SQLite/libSQL - migrations run automatically
# D1 - run via wrangler
wrangler d1 migrations apply DB
```
## Environment-Based Configuration
Use different databases per environment:
```js title="astro.config.mjs"
import { sqlite, libsql, postgres } from "emdash/db";
import { d1 } from "@emdash-cms/cloudflare";
const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });
export default defineConfig({
integrations: [emdash({ database })],
});
```
Or switch based on environment variables:
```js
const database = process.env.DATABASE_URL
? postgres({ connectionString: process.env.DATABASE_URL })
: sqlite({ url: "file:./data.db" });
```

View File

@@ -0,0 +1,223 @@
---
title: Deploy to Node.js
description: Deploy EmDash to any Node.js hosting platform.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash runs on any Node.js 22+ hosting platform. This guide covers deployment to common providers using SQLite and local or S3-compatible storage.
## Prerequisites
- Node.js v22.12.0 or higher
- A Node.js hosting provider or VPS
## Configuration
Configure EmDash for Node.js deployment:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
import emdash, { local, s3 } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
integrations: [
emdash({
database: sqlite({ url: "file:./data/emdash.db" }),
storage: local({
directory: "./data/uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
## Build and Run
1. Build the project:
```bash
npm run build
```
2. Initialize the database:
```bash
npx emdash init --database ./data/emdash.db
```
3. Start the server:
```bash
node ./dist/server/entry.mjs
```
The server runs on `http://localhost:4321` by default.
## Production Storage
For production, use S3-compatible storage instead of local filesystem:
```js title="astro.config.mjs"
import emdash, { s3 } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` }),
storage: s3({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
}),
}),
],
});
```
<Aside>
S3-compatible storage works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
</Aside>
## Docker
Add a `.dockerignore` to keep the build context small:
```plain title=".dockerignore"
node_modules
dist
.git
```
Create a `Dockerfile`:
```dockerfile title="Dockerfile"
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
COPY --from=builder /app/seed ./seed
COPY --from=builder /app/astro.config.mjs ./
RUN mkdir -p data
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["sh", "-c", "npx emdash init && node ./dist/server/entry.mjs"]
```
Build and run:
```bash
docker build -t my-emdash-site .
docker run -p 4321:4321 -v emdash-data:/app/data my-emdash-site
```
Or use Docker Compose:
```yaml title="compose.yaml"
services:
emdash:
build: .
ports:
- "4321:4321"
volumes:
- emdash-data:/app/data
restart: unless-stopped
volumes:
emdash-data:
```
```bash
docker compose up -d
```
## Environment Variables
### Recommended: encryption key
`EMDASH_ENCRYPTION_KEY` is used to encrypt plugin secrets at rest. The
current release validates the key but does not yet use it — plugin
secret encryption is rolling out and will start using this value
automatically when available. Set it now so your deployment is ready.
```bash
npx emdash secrets generate # add the result to your environment
```
The key is provided by you and never stored in the database; only
encrypted ciphertext is. Back it up somewhere durable (a password
manager, KMS, or your team's secret store) — losing it means losing
every secret encrypted with it.
### Optional: stable-value overrides
EmDash auto-generates the preview HMAC secret and commenter-IP hash
salt and persists them in the database on first use. The env vars
below pin them to a value you control — useful when a separate
process needs to share a secret with your main site.
| Variable | Description |
| -------- | ----------- |
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
| `EMDASH_AUTH_SECRET` | Legacy. No longer required. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which wins) so existing installs keep stable commenter-IP hashes across the upgrade. New installs should ignore this. |
### Database and Storage
| Variable | Description | Example |
| ---------------------- | ----------------------- | -------------------------- |
| `DATABASE_PATH` | Path to SQLite database | `/data/emdash.db` |
| `HOST` | Server host | `0.0.0.0` |
| `PORT` | Server port | `4321` |
| `S3_ENDPOINT` | S3 endpoint URL | `https://xxx.r2.cloudflarestorage.com` |
| `S3_BUCKET` | S3 bucket name | `my-media-bucket` |
| `S3_ACCESS_KEY_ID` | S3 access key | `AKIA...` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | `...` |
| `S3_PUBLIC_URL` | Public URL for media | `https://cdn.example.com` |
<Aside type="caution">
Never commit secrets to your repository. Use your platform's secret management (environment variables, secret stores, etc.) for production.
</Aside>
## Persistent Storage
SQLite requires persistent disk storage. Ensure your hosting platform provides:
- A mounted volume or persistent disk
- Write access to the database directory
- Backup mechanisms for the database file
<Aside type="caution">
Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.
</Aside>
## Health Checks
Add a health check endpoint for load balancers:
```astro title="src/pages/health.ts"
export const GET = () => {
return new Response("OK", { status: 200 });
};
```
Configure your platform to check `/health` for liveness probes.

View File

@@ -0,0 +1,310 @@
---
title: Storage Options
description: Configure media storage with R2, S3, or local filesystem.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash stores uploaded media (images, documents, videos) in a configurable storage backend. Choose based on your deployment platform and requirements.
## Overview
| Storage | Best For | Features |
| -------------- | ------------------ | --------------------------- |
| **R2 Binding** | Cloudflare Workers | Zero-config, fast |
| **S3** | Any platform | Signed uploads, CDN support |
| **Local** | Development | Simple filesystem storage |
## Cloudflare R2 (Binding)
Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.
```js title="astro.config.mjs"
import emdash from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
storage: r2({ binding: "MEDIA" }),
}),
],
});
```
### Configuration
| Option | Type | Description |
| ----------- | -------- | ------------------------------------- |
| `binding` | `string` | R2 binding name from `wrangler.jsonc` |
| `publicUrl` | `string` | Optional public URL for the bucket |
### Setup
Add the R2 binding to your Wrangler configuration:
<Tabs>
<TabItem label="wrangler.jsonc">
```jsonc
{
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media"
}
]
}
```
</TabItem>
<TabItem label="wrangler.toml">
```toml
[[r2_buckets]]
binding = "MEDIA"
bucket_name = "emdash-media"
```
</TabItem>
</Tabs>
### Public Access
For public media URLs, enable public access on your R2 bucket:
1. Go to Cloudflare Dashboard > R2 > your bucket
2. Enable public access under Settings
3. Add the public URL to your config:
```js
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxxx.r2.dev",
});
```
<Aside type="tip">
Connect a custom domain to R2 for branded URLs like `https://media.example.com`.
</Aside>
<Aside type="note">
R2 bindings do not support signed upload URLs. If you need direct client uploads, use the S3
adapter with R2 credentials instead.
</Aside>
## S3-Compatible Storage
The S3 adapter works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
<Aside type="caution" title="Install the AWS SDK first">
EmDash uses the AWS SDK at runtime for the S3 adapter but does not bundle it — core is
deliberately SDK-agnostic so R2-only and local-only deployments stay lean. Install the SDK
in your project before using `s3()`:
```sh
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
```
If you skip this step, `astro build` will fail with `Rollup failed to resolve import
"@aws-sdk/client-s3"`. The R2 binding adapter (`r2()`) and local adapter do not need the
SDK and are unaffected.
</Aside>
```js title="astro.config.mjs"
import emdash, { s3 } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
storage: s3({
endpoint: process.env.S3_ENDPOINT,
bucket: process.env.S3_BUCKET,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
region: "auto", // Optional, defaults to "auto"
publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
}),
}),
],
});
```
### Configuration
| Option | Type | Required | Description |
| ----------------- | -------- | -------- | ------------------------------ |
| `endpoint` | `string` | yes | S3 endpoint URL |
| `bucket` | `string` | yes | Bucket name |
| `accessKeyId` | `string` | no\* | Access key |
| `secretAccessKey` | `string` | no\* | Secret key |
| `region` | `string` | no | Region (default: `"auto"`) |
| `publicUrl` | `string` | no | Optional CDN or public URL |
\* Both `accessKeyId` and `secretAccessKey` must be provided together, or both omitted.
### Resolving S3 config from environment variables
Any field omitted from `s3({...})` is read from the matching `S3_*` environment variable
when the process starts. This lets you build a container image once and inject credentials
at boot without a rebuild. Explicit values in `s3({...})` always take precedence over
environment variables.
| Environment variable | Field | Notes |
| ---------------------- | ----------------- | ---------------------------------- |
| `S3_ENDPOINT` | `endpoint` | Must be a valid `http`/`https` URL |
| `S3_BUCKET` | `bucket` | |
| `S3_ACCESS_KEY_ID` | `accessKeyId` | |
| `S3_SECRET_ACCESS_KEY` | `secretAccessKey` | |
| `S3_REGION` | `region` | Defaults to `"auto"` |
| `S3_PUBLIC_URL` | `publicUrl` | Optional CDN prefix |
Environment variables are read from `process.env` when the process starts. This is a
Node-only feature.
```js title="astro.config.mjs — runtime environment variable example"
import emdash, { s3 } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
// s3() with no args: all fields from S3_* environment variables
storage: s3(),
// Or mix: override one field, rest from environment
// storage: s3({ publicUrl: "https://cdn.example.com" }),
}),
],
});
```
<Aside type="note" title="Cloudflare Workers">
This runtime resolution does not work on Cloudflare Workers. Worker secrets and variables
are exposed through the `env` parameter of the fetch handler (or `import { env } from
"cloudflare:workers"`), not through `process.env` — even with the `nodejs_compat` flag
enabled. For Workers deployments:
- If you are using R2, use the [`r2()` adapter](#cloudflare-r2-binding) from
`@emdash-cms/cloudflare`. This is the recommended path.
- If you need a non-R2 S3-compatible backend on Workers, pass explicit values to
`s3({...})` in `astro.config.mjs`. Note that `astro.config.mjs` runs at build time,
so Worker secrets are not directly accessible there; you will need to inject values
through your build pipeline.
</Aside>
### R2 via S3 API
Use S3 credentials with R2 for features like signed upload URLs:
```js
storage: s3({
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
bucket: "emdash-media",
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
publicUrl: "https://pub-xxxx.r2.dev",
});
```
Generate R2 API credentials in the Cloudflare dashboard under R2 > Manage R2 API Tokens.
### MinIO
```js
storage: s3({
endpoint: "https://minio.example.com",
bucket: "emdash-media",
accessKeyId: process.env.MINIO_ACCESS_KEY,
secretAccessKey: process.env.MINIO_SECRET_KEY,
publicUrl: "https://minio.example.com/emdash-media",
});
```
## Local Filesystem
Use local storage for development. Files are stored in a directory on disk.
```js title="astro.config.mjs"
import emdash, { local } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
### Configuration
| Option | Type | Description |
| ----------- | -------- | ------------------------------- |
| `directory` | `string` | Directory path for file storage |
| `baseUrl` | `string` | Base URL for serving files |
The `baseUrl` should match EmDash's media file endpoint (`/_emdash/api/media/file`) unless you configure a custom static file server.
<Aside type="caution">
Local storage does not support signed upload URLs. Files are uploaded through the server, which
may be slower for large files.
</Aside>
## Environment-Based Configuration
Switch storage backends based on environment:
```js title="astro.config.mjs"
import emdash, { s3, local } from "emdash/astro";
import { r2 } from "@emdash-cms/cloudflare";
const storage = import.meta.env.PROD
? r2({ binding: "MEDIA" })
: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
export default defineConfig({
integrations: [emdash({ storage })],
});
```
## Signed Uploads
The S3 adapter supports signed upload URLs, allowing clients to upload directly to storage without passing through your server. This improves performance for large files.
Signed uploads are automatic when using the S3 adapter. The admin interface uses them when available.
Adapters that support signed uploads:
- **S3** (including R2 via S3 API)
Adapters that do not support signed uploads:
- **R2 binding** (use S3 adapter with R2 credentials instead)
- **Local**
## Storage Interface
All storage adapters implement the same interface:
```typescript
interface Storage {
upload(options: {
key: string;
body: Buffer | Uint8Array | ReadableStream;
contentType: string;
}): Promise<UploadResult>;
download(key: string): Promise<DownloadResult>;
delete(key: string): Promise<void>;
exists(key: string): Promise<boolean>;
list(options?: ListOptions): Promise<ListResult>;
getSignedUploadUrl(options: SignedUploadOptions): Promise<SignedUploadUrl>;
getPublicUrl(key: string): string;
}
```
This consistency allows switching storage backends without changing application code.

View File

@@ -0,0 +1,161 @@
---
title: EmDash Docs MCP
description: Connect your AI coding assistant to the EmDash documentation so it can answer questions and find the right pattern from real docs.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
The EmDash documentation site exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://docs.emdashcms.com/mcp`. Connect your coding assistant to it and the assistant can search the docs as you work, instead of guessing from training data that may be out of date.
This is **separate from your site's MCP server** (covered in the [AI Tools guide](/guides/ai-tools)). The docs MCP only knows about EmDash's documentation -- it cannot read or modify your content. Most developers want both: the docs MCP to look things up, and the site MCP to manage content.
## What it does
The docs MCP exposes a single tool:
| Tool | Purpose |
| ------------- | --------------------------------------------------------------------------------------------- |
| `search_docs` | Search the EmDash documentation. Returns relevant chunks with source URLs and match scores. |
Behind the scenes it uses [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) over an index built from `docs.emdashcms.com`. The crawler keeps the index in sync with the published site, so answers reflect the docs you're reading.
## Connect it
The endpoint is:
```
https://docs.emdashcms.com/mcp
```
No authentication, no API key. It's public and read-only.
### Auto-discovery in EmDash templates
If you started your project from an EmDash template (`npm create emdash`), three config files are already in place and will be picked up automatically:
| File | Used by |
| ------------------- | ------------ |
| `.mcp.json` | Claude Code |
| `.cursor/mcp.json` | Cursor |
| `.vscode/mcp.json` | VS Code |
Just open the project and accept the workspace-trust prompt your tool shows on first run. Nothing else to do.
### Manual setup
If you're not using a template, or you use a different tool, add it once with the snippet for your client:
<Tabs>
<TabItem label="Claude Code">
Add the server with the Claude Code CLI:
```bash
claude mcp add --transport http emdash-docs https://docs.emdashcms.com/mcp
```
Or commit a `.mcp.json` at your project root:
```json
{
"mcpServers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
```
</TabItem>
<TabItem label="OpenCode">
Add to your `opencode.jsonc`:
```jsonc
{
"mcp": {
"emdash-docs": {
"type": "remote",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
```
</TabItem>
<TabItem label="Cursor">
Commit `.cursor/mcp.json` at your project root, or add it via **Cursor Settings -> MCP -> Add new MCP server**:
```json
{
"mcpServers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
```
</TabItem>
<TabItem label="VS Code">
Add to `.vscode/mcp.json` in your project (or your user settings):
```json
{
"servers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
```
</TabItem>
<TabItem label="Claude Desktop">
Claude Desktop only supports stdio MCP servers natively, so use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge. Add to `claude_desktop_config.json`:
```json
{
"mcpServers": {
"emdash-docs": {
"command": "npx",
"args": ["mcp-remote", "https://docs.emdashcms.com/mcp"]
}
}
}
```
</TabItem>
</Tabs>
## When to use it
- You're building an EmDash site and want your AI assistant to look up the correct API, hook name, or config option from current docs rather than half-remembered training data.
- You're writing a plugin and want to find which hooks fire in what order.
- You're porting a WordPress theme and want examples of seed file patterns.
- You're stuck on an error and want to search release notes and concepts.
## Recommend it in your AGENTS.md
If your project uses `AGENTS.md` (or `CLAUDE.md`, `.cursorrules`, etc.) to instruct AI tools, point them at the docs MCP so they prefer real documentation over assumptions:
```markdown title="AGENTS.md"
## Documentation
Look up EmDash documentation via the `emdash-docs` MCP server when you need to
verify an API, hook, config option, or pattern. Prefer the docs MCP over
assumptions from training data -- the docs reflect the current published
behaviour.
```
The EmDash starter templates ship with this snippet pre-included.
<Aside title="Privacy">
The docs MCP is hosted by the EmDash project. Queries are sent to `docs.emdashcms.com` and processed by Cloudflare AI Search. Don't include private code, secrets, or user data in queries -- the tool is designed for documentation lookup, not code analysis.
</Aside>

View File

@@ -0,0 +1,272 @@
---
title: Getting Started
description: Create your first EmDash site in under 5 minutes.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
import dashboardImg from "../../assets/screenshots/admin-dashboard.png";
import postEditorImg from "../../assets/screenshots/admin-post-editor.png";
This guide walks you through creating your first EmDash site, from installation to publishing your first post.
## Prerequisites
- **Node.js** v22.12.0 or higher (odd-numbered versions are not supported)
- **npm**, **pnpm**, or **yarn**
- A code editor (VS Code recommended)
## Create a New Project
<Tabs>
<TabItem label="npm">
```bash
npm create emdash@latest
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm create emdash@latest
```
</TabItem>
<TabItem label="yarn">
```bash
yarn create emdash
```
</TabItem>
</Tabs>
Follow the prompts to name your project and set up your preferences.
## Start the Development Server
<Steps>
1. Navigate to your project directory:
```bash
cd my-emdash-site
```
2. Install dependencies:
```bash
npm install
```
3. Start the dev server:
```bash
npm run dev
```
4. Open your browser to `http://localhost:4321`
</Steps>
## Complete the Setup Wizard
When you first visit the admin panel, EmDash's Setup Wizard guides you through initial configuration:
<Steps>
1. Navigate to `http://localhost:4321/_emdash/admin`
2. You'll be redirected to the Setup Wizard. Enter:
- **Site Title** — Your site's name
- **Tagline** — A short description
- **Admin Email** — Your email address
3. Click **Create Site** to register your passkey
4. Your browser will prompt you to create a passkey using Touch ID, Face ID, Windows Hello, or a security key
</Steps>
Once your passkey is registered, you're logged in and redirected to the admin dashboard.
<img src={dashboardImg.src} alt="EmDash admin dashboard showing content overview, recent activity, and navigation sidebar" />
<Aside>
EmDash uses passkey authentication instead of passwords. Passkeys are more secure and work with
your browser's built-in password manager. See the [Authentication guide](/guides/authentication/)
for more details.
</Aside>
## Your First Post
<Steps>
1. In the admin sidebar, click **Posts** under Content.
2. Click **New Post**.
3. Enter a title and write some content using the rich text editor.
<img src={postEditorImg.src} alt="EmDash post editor with rich text toolbar and publish sidebar" />
4. Set the status to **Published** and click **Save**.
5. Visit your site's homepage to see your post live—no rebuild needed!
</Steps>
<Aside type="tip">
EmDash uses Live Content Collections. Changes appear immediately without rebuilding your site.
</Aside>
## Project Structure
Your EmDash project follows a standard Astro structure with a few additions:
```
my-emdash-site/
├── astro.config.mjs # Astro + EmDash configuration
├── src/
│ ├── live.config.ts # Live Collections configuration
│ ├── pages/
│ │ ├── index.astro # Homepage
│ │ └── posts/
│ │ └── [...slug].astro # Dynamic post pages
│ ├── layouts/
│ │ └── Base.astro # Base layout
│ └── components/ # Your Astro components
├── .emdash/
│ ├── seed.json # Template seed file
│ └── types.ts # Generated TypeScript types
└── package.json
```
## Configuration Files
### astro.config.mjs
This configures EmDash as an Astro integration:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
### src/live.config.ts
This connects EmDash to Astro's content system:
```ts title="src/live.config.ts"
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```
<Aside>
EmDash uses a single `_emdash` collection that internally routes to your content types (posts,
pages, products, etc.). This keeps your `live.config.ts` minimal.
</Aside>
### Environment Variables
For production, set an encryption key for plugin secrets:
```bash
npx emdash secrets generate
```
Add the result to your environment as `EMDASH_ENCRYPTION_KEY`. This
key is used to encrypt plugin secrets at rest. The current release
validates the key but does not yet use it to encrypt anything; plugin
secret encryption is rolling out and will start using this value
automatically when available. Set it now so your deployment is ready.
```bash
EMDASH_ENCRYPTION_KEY=emdash_enc_v1_...
```
The key is never stored in the database — only encrypted ciphertext is.
Back it up somewhere durable; losing it means losing every secret
encrypted with it.
The preview HMAC secret and commenter-IP hash salt are auto-generated
and stored in the database on first use; you don't need to set them.
You can override either if a separate process needs to share the value
with your main site:
```bash
# Optional override — only needed if another process verifies these tokens
EMDASH_PREVIEW_SECRET=your-preview-secret
```
## Query Content in Pages
Use EmDash's query functions in your Astro pages. These follow Astro's live collections pattern, returning `{ entries, error }` for collections and `{ entry, error }` for single entries:
```astro title="src/pages/index.astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
const { entries: posts } = await getEmDashCollection("posts");
---
<Base title="Home">
<h1>Latest Posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
For single entries:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
---
<h1>{post.data.title}</h1>
```
## Generate TypeScript Types
For full type safety, generate types from your database schema:
```bash
npx emdash types
```
This creates `.emdash/types.ts` with interfaces for all your collections. Your editor will now autocomplete field names and catch type errors.
## Next Steps
You now have a working EmDash site! Here's where to go next:
- **[Core Concepts](/concepts/architecture/)** — Understand how EmDash works under the hood
- **[Working with Content](/guides/working-with-content/)** — Learn to query and render content
- **[Media Library](/guides/media-library/)** — Upload and manage images and files
- **[Create a Blog](/guides/create-a-blog/)** — Build a complete blog with categories and tags
- **[Deploy to Cloudflare](/deployment/cloudflare/)** — Take your site to production

View File

@@ -0,0 +1,178 @@
---
title: AI Tools
description: Connect Claude, ChatGPT, and other AI assistants to your EmDash site.
---
import { Aside, Steps, Tabs, TabItem, LinkCard } from "@astrojs/starlight/components";
EmDash has a built-in [MCP server](https://modelcontextprotocol.io) that lets AI assistants work directly with your site's content. You can ask Claude, ChatGPT, or other tools to draft posts, update pages, manage media, search your content, and more -- all through natural conversation.
<Aside>
This guide covers your **site's** MCP server -- for managing the content of an EmDash site. If you want to give an AI coding assistant access to the EmDash documentation itself (so it can answer questions about EmDash as you build), see [Docs MCP for AI Tools](/docs-mcp) instead. Most developers connect both.
</Aside>
## Enable the MCP Server
The MCP server is disabled by default. Enable it in your Astro configuration:
```js title="astro.config.mjs"
emdash({
mcp: true,
})
```
## Setting Up
Your site's MCP server URL is:
```
https://example.com/_emdash/api/mcp
```
Replace `example.com` with your domain. For local development, use `http://localhost:4321/_emdash/api/mcp`.
<Tabs>
<TabItem label="Claude">
Connectors added in [claude.ai](https://claude.ai) work in both the web app and Claude Desktop.
<Steps>
1. Go to [Settings > Connectors](https://claude.ai/settings/connectors)
2. Click **Add custom connector**
3. Enter your site's MCP server URL
4. Click **Add** -- your browser opens for you to log in and approve access
5. Start a new conversation, click **+** in the chat input, then **Connectors**, and toggle your site on
</Steps>
For Team and Enterprise plans, an Owner first adds the connector from [Admin Settings > Connectors](https://claude.ai/admin-settings/connectors). Members then connect individually from their own settings.
</TabItem>
<TabItem label="ChatGPT">
ChatGPT supports MCP servers on Pro, Business, and Enterprise plans.
<Steps>
1. Go to **Settings > Apps & Connectors > Advanced settings** and enable **Developer Mode**
2. Go to **Settings > Connectors > Create**
3. Enter a name, description, and your site's MCP server URL
4. Click **Create**
5. In a conversation, click **+** near the composer, then **More**, and select your connector
</Steps>
</TabItem>
</Tabs>
<Aside>
Using a coding tool like VS Code, Cursor, or Windsurf? See the [MCP Server Reference](/reference/mcp-server) for configuration details.
</Aside>
## What You Can Do
Once connected, you can ask the AI assistant to perform any of these operations in natural language. You don't need to know the tool names -- just describe what you want.
### Content
- **Browse content** -- "Show me the latest 10 blog posts" or "Find all draft pages"
- **Read content** -- "Get the post called 'hello-world' and summarize it"
- **Create content** -- "Write a new blog post about our summer sale" or "Create a draft page for the About section"
- **Edit content** -- "Update the pricing page to mention the new plan" or "Fix the typo in the FAQ post"
- **Publish and schedule** -- "Publish the summer sale post", "Schedule the announcement for June 1st at 9am", or "Cancel the schedule on the launch post"
- **Compare versions** -- "Show me what changed in the homepage since it was last published"
- **Manage drafts** -- "Discard the draft changes on the about page" or "Duplicate the newsletter template"
- **Translations** -- "What translations exist for the welcome post?" (when i18n is enabled)
### Media
- **Browse media** -- "List all uploaded images" or "Show me PDFs in the media library"
- **Check details** -- "Get the details for this media item"
- **Register uploads** -- "Register the file I just uploaded to `media/2026/banner.png` as a media item"
- **Update metadata** -- "Set the alt text on the hero image to 'Mountain sunset'"
- **Remove files** -- "Delete the old banner image"
<Aside>
The MCP transport can't carry binary uploads. To add a new image, upload the bytes via the admin UI (or your own signed-upload flow) and then ask the AI to register the metadata.
</Aside>
### Search
- **Find content** -- "Search for posts mentioning 'accessibility'" or "Find anything about TypeScript across all collections"
### Taxonomies
- **Browse** -- "List all categories" or "Show me the tags"
- **Create terms** -- "Add a 'tutorials' tag" or "Create a 'Frontend' subcategory under 'Engineering'"
- **Rename terms** -- "Rename the 'frontend' category to 'Web Frontend'"
- **Move terms** -- "Move the 'tutorials' tag under the 'guides' category" or "Detach 'react' from its parent"
- **Delete terms** -- "Delete the unused 'archive' tag"
### Menus
- **View menus** -- "Show me the main navigation menu" or "What's in the footer menu?"
- **Create menus** -- "Create a new 'sidebar' menu"
- **Edit menus** -- "Rename the 'main' menu to 'Primary navigation'"
- **Set items** -- "Replace the items in the main menu with Home, Blog, About, and Contact"
- **Delete menus** -- "Delete the unused 'mobile' menu"
### Site Settings
- **Inspect** -- "What's the current site title?" or "Show me the social links"
- **Update identity** -- "Set the site title to 'Acme Blog' and tagline to 'Stories from the team'"
- **Set logo / favicon** -- "Use this image as the site logo" (after registering it with `media_create`)
- **SEO defaults** -- "Set the default OG image to the new banner" or "Update the title separator to a vertical bar"
- **Social handles** -- "Add our Mastodon and YouTube links to the social settings"
<Aside>
Reading site settings requires the Editor role; updating them requires Admin.
</Aside>
### Schema (Admin only)
- **Inspect** -- "What collections exist?" or "Show me the fields on the posts collection"
- **Create collections** -- "Create a new 'testimonials' collection with name and quote fields"
- **Modify schema** -- "Add a 'featured' boolean field to posts"
<Aside type="caution">
Schema changes (creating/deleting collections and fields) modify your database structure and require Admin permissions. The AI will tell you if you don't have sufficient access.
</Aside>
### Revisions
- **View history** -- "Show the revision history for this post"
- **Restore** -- "Restore the post to its previous version"
## Permissions
What you can do through an AI tool depends on your EmDash role. The AI assistant operates with the same permissions you have in the admin panel:
| Role | What the AI can do |
| --- | --- |
| **Admin** | Everything, including schema changes and updating site settings |
| **Editor** | All content, media, taxonomies, and menus. Can view schema and read settings. |
| **Author** | Own content and media |
| **Contributor** | Own content (no publishing) and media |
If you try something you don't have access to, the AI will let you know.
## Tips
- **Be specific about collections.** Say "create a blog post" rather than "create a post" if you have multiple collections.
- **Ask for the schema first.** If you're unsure what fields a collection has, ask "What fields does the posts collection have?" before creating or editing content.
- **Review before publishing.** Ask the AI to create content as a draft, review it in the admin panel, then ask the AI to publish it -- or publish it yourself.
- **Use compare for review.** Before publishing, ask "Compare the live and draft versions of this post" to see exactly what will change.
- **Rich text fields use Portable Text.** The AI can write content for rich text fields, but complex formatting is best done in the admin editor.
## For Developers
The MCP server endpoint, authentication methods, OAuth discovery, tool parameters, and error handling are documented in the [MCP Server Reference](/reference/mcp-server).

View File

@@ -0,0 +1,155 @@
---
title: Atmosphere Login
description: Sign in to EmDash with an Atmosphere account — the open-network identity behind Bluesky and the wider AT Protocol ecosystem.
---
import { Aside, Steps } from "@astrojs/starlight/components";
The `@emdash-cms/auth-atproto` package adds an [**Atmosphere account**](https://atmosphereaccount.com) login option to EmDash. An Atmosphere account is a portable, user-owned identity used across [Bluesky](https://bsky.app) and other apps in the AT Protocol network. Users sign in with their handle (e.g. `alice.bsky.social`) and authenticate at their own provider — EmDash never sees a password.
This is a good fit when:
- Your contributors already have an Atmosphere account.
- You want to gate an org-controlled domain (`*.yourcompany.com`) without managing OAuth apps or invites.
- You're building something that's part of the wider Atmosphere and want consistent identity with the rest of your stack.
## Install
```bash
pnpm add @emdash-cms/auth-atproto
```
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { atproto } from "@emdash-cms/auth-atproto";
export default defineConfig({
integrations: [
emdash({
authProviders: [atproto()],
server: {
host: "127.0.0.1", // required for local dev — see "Local development" below
},
}),
],
});
```
That's enough to put **Sign in with Atmosphere** on the login page and the setup wizard. With no allowlist configured, the first user becomes Admin and self-signup is closed for everyone after that — see [allowlists](#allowlists) to open it up.
No environment variables, client secret, or OAuth-app registration is required. The provider is a public OAuth client and serves its own metadata document at `/.well-known/atproto-client-metadata.json`.
## Configuration
```js
atproto({
allowedDIDs: ["did:plc:abc123..."],
allowedHandles: ["*.example.com", "alice.bsky.social"],
defaultRole: 30, // Author
});
```
| Option | Type | Default | Description |
| ---------------- | ---------- | ------------------------- | ---------------------------------------------------------------------------- |
| `allowedDIDs` | `string[]` | none (allow all on first) | DID allowlist. DIDs are permanent and can't be spoofed. |
| `allowedHandles` | `string[]` | none (allow all on first) | Handle allowlist. Supports wildcards (`*.example.com`). |
| `defaultRole` | `number` | `10` (Subscriber) | Role assigned to allowed users after the first. First user is always Admin. |
The full role ladder is documented in the main [authentication guide](/guides/authentication/#user-roles).
### Allowlists
If neither `allowedDIDs` nor `allowedHandles` is set, only the **first user** can sign up — anyone else attempting to log in will be rejected with `signup_not_allowed`. Existing users can always sign back in regardless of the allowlist (so removing yourself from the list won't lock you out, but won't let new people in either).
When at least one allowlist is configured, a user is admitted if **either** list matches:
- **DID match.** The user's DID is in `allowedDIDs`. DIDs are cryptographic identifiers that can't be moved or impersonated, so this is the strictest form of gating.
- **Handle match.** The user's handle matches an entry in `allowedHandles`, exactly or via a leading-wildcard pattern (`*.example.com` matches `alice.example.com` and `bob.team.example.com`).
Handle allowlists are safe even though handles are mutable. Before admitting a user via a handle match, EmDash independently resolves the handle's DNS/HTTP record and verifies that it points at the same DID the provider claims. A misbehaving provider can't simply assert it owns `you@yourcompany.com`.
<Aside>
Use `allowedDIDs` for individuals and `allowedHandles` with a wildcard for org-level access. The
two are additive — list specific external DIDs alongside a wildcard for staff handles.
</Aside>
### Default role
Allowed users land on the role you set in `defaultRole`. Only the first user — the one who completes setup — is forced to Admin. There's no group/role mapping for Atmosphere accounts; if you need finer-grained roles, change the user's role from **Settings → Users** after they've logged in once.
## First-user setup
When you start a fresh site with the Atmosphere provider configured, the setup wizard offers it as an option for creating the initial admin account.
<Steps>
1. Visit `/_emdash/admin`. The setup wizard takes you through site title, tagline, and admin email.
2. On the "Create admin account" step, choose **Atmosphere** and enter your handle (e.g. `alice.bsky.social`).
3. You'll be redirected to your account's authorization page, where you sign in however your provider supports — password, passkey, or whatever else.
4. After approval you're sent back to EmDash, the admin user is created with role 50 (Admin), and the email you entered in step 1 is stored against your account.
</Steps>
The same flow runs for every subsequent login — handle in, redirect to your provider, redirect back, you're signed in.
## Local development
The AT Protocol OAuth profile requires loopback redirect URIs to use an **IP literal** (`127.0.0.1` or `[::1]`), not `localhost`. EmDash transparently rewrites `://localhost` to `://127.0.0.1` when generating the redirect URI, but that means your dev session needs to start on `127.0.0.1` too — otherwise the session cookie set on `localhost` won't be visible after the redirect lands you on `127.0.0.1`.
Astro's dev server is Vite's dev server, and Vite binds to `localhost` by default. Tell it to listen on the loopback IP as well:
```js title="astro.config.mjs"
export default defineConfig({
server: {
host: "127.0.0.1",
},
// ...
});
```
Then open `http://127.0.0.1:4321/_emdash/admin` for the whole flow.
<Aside type="caution">
If you stay on `http://localhost:4321`, the round-trip to your provider succeeds but you arrive
back on `127.0.0.1` with no session cookie and bounce back to the login page. This is a
dev-server-binding issue, not a bug in the auth flow.
</Aside>
## Production
There's nothing extra to configure for production. The provider serves its own client metadata at:
```
https://your-site.example.com/.well-known/atproto-client-metadata.json
```
Authorization servers fetch this URL during the login dance to verify the client's redirect URI. Make sure your deployment's site URL is reachable on the public internet over HTTPS — internal-only deployments behind a VPN won't be able to complete a login because the user's authorization server can't fetch the metadata document.
If you run EmDash behind a TLS-terminating reverse proxy, set [`siteUrl`](/reference/configuration#siteurl) so EmDash builds the right redirect URI. Without it, requests look like `http://internal-host:4321` and the metadata won't match what the auth server sees.
## Troubleshooting
### "Account is not in the allowlist"
The handle or DID you signed in with isn't in `allowedDIDs` / `allowedHandles`. Check the wildcard pattern (it must start with `*.`) and remember the handle match is verified against DNS/HTTP — if the handle's DID record doesn't currently resolve to the same DID the provider returned, the match is rejected.
### "Self-signup is not allowed"
You hit the callback successfully but no allowlist is configured and you aren't the first user. Either add yourself to `allowedDIDs`/`allowedHandles`, or have an existing admin invite you so the user already exists when you log in.
### Login redirects to the login page with no error
This is almost always the loopback-cookie issue described in [Local development](#local-development). Open the admin at `http://127.0.0.1:4321` (after setting `server.host: "127.0.0.1"`) and try again.
### Handle resolution fails for a self-hosted handle
The provider verifies handles by racing DNS-over-HTTPS (Cloudflare's DoH endpoint) and an HTTP `/.well-known/atproto-did` lookup. Self-hosted handles need at least one of:
- A `_atproto.<handle>` DNS TXT record containing `did=<your-did>`, or
- An `https://<handle>/.well-known/atproto-did` file containing the DID.
If both methods fail, the handle match is rejected even when the underlying account is valid. DIDs in `allowedDIDs` aren't affected — they're matched directly.

View File

@@ -0,0 +1,454 @@
---
title: Authentication
description: Passkey-first authentication with pluggable providers for GitHub, Google, and the Atmosphere.
---
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
EmDash uses passkey authentication as its primary login method. Passkeys are phishing-resistant, don't require passwords, and work across devices through your browser or password manager.
Beyond passkeys, you can add **pluggable login providers** — GitHub, Google, and [the Atmosphere](/guides/atmosphere-auth/) (AT Protocol) ship out of the box, and the same provider interface is open for third-party packages. Any configured provider can be used to create the first admin account, log in, or link to an existing user.
For Cloudflare deployments, [Cloudflare Access](#cloudflare-access) is also available as an exclusive auth method that takes over the entire login flow.
## How It Works
Passkeys use WebAuthn, a web standard that creates public-key credentials stored on your device or synced through your password manager. When you log in, your device proves possession of the credential without ever sending a password over the network.
Benefits of passkey authentication:
- **No passwords to remember or leak**
- **Phishing-resistant** — credentials are bound to your site's domain
- **Cross-device sync** — works with iCloud Keychain, Google Password Manager, 1Password, etc.
- **Fast login** — one tap with biometrics or PIN
## First User Setup
The first time you access the admin panel, the Setup Wizard guides you through creating your admin account.
<Steps>
1. Navigate to `http://localhost:4321/_emdash/admin`
2. You'll be redirected to the Setup Wizard. Enter:
- **Site Title** — Your site's name
- **Tagline** — A short description
- **Admin Email** — Your email address
3. Click **Create Site** to register your passkey
4. Your browser will prompt you to create a passkey:
- On macOS: Touch ID, device password, or security key
- On Windows: Windows Hello or security key
- On mobile: Face ID, fingerprint, or PIN
5. Once your passkey is registered, you're logged in and redirected to the admin dashboard.
</Steps>
<Aside>
Your email is stored but not verified during initial setup. You can configure email later to
enable features like invites and magic link login.
</Aside>
## Logging In
After setup, returning to the admin panel triggers passkey authentication:
<Steps>
1. Visit `/_emdash/admin`
2. If not logged in, you'll see the login page
3. Click **Sign in** to authenticate
4. Your browser prompts for your passkey (biometrics, PIN, or security key)
5. After verification, you're redirected to the admin dashboard
</Steps>
## Magic Link Fallback
If you can't use your passkey (e.g., lost device), magic links provide an alternative. This requires email to be configured.
<Steps>
1. On the login page, click **Sign in with email**
2. Enter your email address
3. Check your inbox for a login link
4. Click the link to authenticate (valid for 15 minutes)
</Steps>
<Aside type="caution">
Magic links are single-use and expire after 15 minutes. Request a new link if yours has expired.
</Aside>
## Login Providers
In addition to passkeys, EmDash supports pluggable login providers that appear on the login page and in the setup wizard. GitHub, Google, and Atmosphere providers ship in the box; third-party packages can register their own using the same interface.
Providers are additive — passkeys keep working when providers are enabled, and users can link a provider to an existing passkey-only account. The first user can also be created through any configured provider, so a fresh install can skip passkeys entirely if you prefer.
### Configuring providers
Pass providers to the `authProviders` array on the EmDash integration:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { atproto } from "@emdash-cms/auth-atproto";
export default defineConfig({
integrations: [
emdash({
authProviders: [github(), google(), atproto()],
}),
],
});
```
Order matters for the login page: providers render in the order you list them, with compact button-only providers first and providers that need a custom form (like Atmosphere, which asks for a handle) shown after.
### GitHub
```js
import { github } from "emdash/auth/providers/github";
emdash({ authProviders: [github()] });
```
Set credentials via environment variables. EmDash checks the prefixed names first and falls back to the unprefixed ones:
| Variable | Purpose |
| ------------------------------------------------------------- | ---------------------- |
| `EMDASH_OAUTH_GITHUB_CLIENT_ID` / `GITHUB_CLIENT_ID` | OAuth app client ID |
| `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` / `GITHUB_CLIENT_SECRET` | OAuth app secret |
Configure your GitHub OAuth app's callback URL as `https://your-site.example.com/_emdash/api/auth/oauth/github/callback`.
### Google
```js
import { google } from "emdash/auth/providers/google";
emdash({ authProviders: [google()] });
```
| Variable | Purpose |
| ------------------------------------------------------------- | ---------------------- |
| `EMDASH_OAUTH_GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_ID` | OAuth app client ID |
| `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET` / `GOOGLE_CLIENT_SECRET` | OAuth app secret |
Configure your Google OAuth client's redirect URI as `https://your-site.example.com/_emdash/api/auth/oauth/google/callback`.
### Atmosphere (AT Protocol)
For sites where contributors already have an account on the AT Protocol network — the same identity that powers Bluesky and a growing collection of other apps — install the Atmosphere provider:
```bash
pnpm add @emdash-cms/auth-atproto
```
```js
import { atproto } from "@emdash-cms/auth-atproto";
emdash({
authProviders: [
atproto({
allowedHandles: ["*.example.com"],
}),
],
});
```
No client secret or environment variable is needed. See the [Atmosphere login guide](/guides/atmosphere-auth/) for handle/DID allowlists, role mapping, and the local-development setup that the AT Protocol OAuth profile requires.
### Building your own provider
A provider is just an `AuthProviderDescriptor` — an `id`, a human label, and any combination of admin-side React components, route handlers, public route prefixes, and storage collections. The shape is exported from `emdash`:
```ts
import type { AuthProviderDescriptor } from "emdash";
export function myProvider(): AuthProviderDescriptor {
return {
id: "my-provider",
label: "My Provider",
adminEntry: "my-provider/admin", // exports LoginButton / LoginForm / SetupStep
routes: [
{ pattern: "/_emdash/api/auth/my-provider/login", entrypoint: "my-provider/routes/login.ts" },
{ pattern: "/_emdash/api/auth/my-provider/callback", entrypoint: "my-provider/routes/callback.ts" },
],
publicRoutes: ["/_emdash/api/auth/my-provider/"],
storage: {
sessions: {},
},
};
}
```
The Atmosphere package (`@emdash-cms/auth-atproto`) is the most complete real-world reference for a provider that needs a custom login form, OAuth route handlers, and persistent storage.
## User Roles
EmDash uses role-based access control with five levels:
| Role | Level | Description |
| ----------- | ----- | ------------------------------------------ |
| Subscriber | 10 | Read published content (no draft access) |
| Contributor | 20 | Create content (needs approval to publish) |
| Author | 30 | Create/edit/publish own content |
| Editor | 40 | Manage all content |
| Admin | 50 | Full access including settings |
Each role inherits permissions from all lower levels. The first user is always created as Admin.
### Subscribers and draft content
Subscribers hold the `content:read` permission so member-only published content can be served to authenticated readers. They cannot see drafts, scheduled items, trashed items, revisions, or preview URLs — those are gated on `content:read_drafts`, granted to Contributor and above. The list and get endpoints transparently filter to `status=published` for Subscribers; editor-only views (`/compare`, `/revisions`, `/trash`, `/preview-url`) reject Subscriber requests outright.
## Inviting Users
Admins can invite new users via the admin panel:
<Steps>
1. Go to **Settings** > **Users**
2. Click **Invite User**
3. Enter the user's email and select a role
4. Click **Send Invite**
5. The user receives an email with an invite link
6. They click the link and register their passkey
</Steps>
Invites are valid for 7 days. Admins can resend or revoke invites from the Users page.
## Managing Passkeys
Users can manage their passkeys from the account settings:
- **Add passkey** — Register additional passkeys for backup or other devices
- **Remove passkey** — Delete passkeys you no longer use
- **Rename passkey** — Give passkeys descriptive names
Each user can have up to 10 passkeys registered.
<Aside type="tip">
Register passkeys on multiple devices (e.g., laptop and phone) to ensure you always have a way to
log in.
</Aside>
## Self-Signup
For team sites, you can enable self-signup for specific email domains:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
auth: {
selfSignup: {
domains: ["example.com"],
defaultRole: "contributor",
},
},
}),
],
});
```
Users with matching email domains can sign up without an invite. They'll receive a verification email and register a passkey to complete signup.
## Session Configuration
Sessions use secure HttpOnly cookies with sensible defaults:
```js title="astro.config.mjs"
emdash({
auth: {
session: {
maxAge: 30 * 24 * 60 * 60, // 30 days (default)
sliding: true, // Reset expiry on activity
},
},
});
```
## Security Notes
- **Passkeys are stored as public keys** — the private key never leaves your device
- **Challenge verification** prevents replay attacks
- **Rate limiting** protects against brute force (5 attempts/minute/IP)
- **Sessions are HttpOnly, Secure, SameSite=Lax** for cookie security
- **Magic link tokens are SHA-256 hashed** — raw tokens are never stored
## Troubleshooting
### "No passkeys registered"
If you see this error on login, your passkey may have been deleted from your password manager. Ask an admin to send you a magic link or new invite.
### "Passkey authentication failed"
This usually means the passkey was created for a different domain. Passkeys are domain-bound — a passkey for `localhost:4321` won't work on `example.com`. Register a new passkey for each domain.
### "Session expired"
Sessions last 30 days by default with sliding expiration. If you're logged out unexpectedly, clear your cookies and log in again.
### Lost all passkeys
If you've lost access to all your registered passkeys:
1. Ask another admin to send you a magic link (requires email configuration)
2. Use the magic link to log in
3. Register a new passkey in account settings
If you're the only admin and email isn't configured, you'll need to reset your site's authentication through the database.
## Cloudflare Access
When deploying to Cloudflare, you can use [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/) as your authentication provider instead of passkeys. Access handles authentication at the edge using your existing identity provider.
<Aside>
When Cloudflare Access is configured, it becomes the **exclusive** authentication method.
Passkeys, OAuth, magic links, and self-signup are all disabled.
</Aside>
### Why Use Cloudflare Access?
- **Single Sign-On** — Users authenticate with your company's IdP
- **Centralized access control** — Manage who can access the admin in the Cloudflare dashboard
- **No passkey management** — No need to register or manage passkeys
- **Group-based roles** — Map IdP groups to EmDash roles automatically
### Setup
1. Create a [Cloudflare Access application](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/) for your EmDash site
2. Note the **Application Audience (AUD) Tag** from the application settings
3. Configure EmDash to use Access:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import emdash from "emdash/astro";
import { d1, access } from "@emdash-cms/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
emdash({
database: d1({ binding: "DB" }),
auth: access({
teamDomain: "myteam.cloudflareaccess.com",
audience: "abc123def456...", // From Access app settings
}),
}),
],
});
```
### Configuration Options
| Option | Type | Default | Description |
| --------------- | --------- | -------- | ------------------------------------------------------------- |
| `teamDomain` | `string` | required | Your Access team domain (e.g., `myteam.cloudflareaccess.com`) |
| `audience` | `string` | required | Application Audience (AUD) tag from Access settings |
| `autoProvision` | `boolean` | `true` | Create EmDash users on first Access login |
| `defaultRole` | `number` | `30` | Role for users not matching any group (30 = Author) |
| `syncRoles` | `boolean` | `false` | Update role on each login based on IdP groups |
| `roleMapping` | `object` | — | Map IdP group names to role levels |
| `audienceEnvVar`| `string` | `"CF_ACCESS_AUDIENCE"` | Environment variable name for the audience tag (alternative to hardcoding) |
### Role Mapping
Map your IdP groups to EmDash roles:
```js title="astro.config.mjs"
emdash({
auth: access({
teamDomain: "myteam.cloudflareaccess.com",
audience: "abc123...",
roleMapping: {
Admins: 50, // Admin
"Content Editors": 40, // Editor
Writers: 30, // Author
},
defaultRole: 20, // Contributor for users not in any group
}),
});
```
The first matching group wins if a user belongs to multiple groups. The **first user** to access the site always becomes Admin, regardless of groups.
### Role Sync Behavior
By default (`syncRoles: false`), a user's role is set when they first log in and doesn't change afterward. This allows admins to manually adjust roles in EmDash.
Set `syncRoles: true` if you want IdP groups to be authoritative — the user's role will update on every login based on their current groups.
### How It Works
1. User visits `/_emdash/admin`
2. Cloudflare Access intercepts and redirects to your IdP
3. User authenticates (SSO, MFA, etc.)
4. Access sets a signed JWT in the request
5. EmDash validates the JWT and creates/authenticates the user
<Aside type="tip">
You can still disable users locally in EmDash. A disabled user will be rejected even if Access
allows them through.
</Aside>
### Disabled Features
When Access is enabled, these features are unavailable:
- Login page (`/_emdash/admin/login`)
- Passkey registration and management
- OAuth login
- Magic link login
- Self-signup
- User invites
User management is done entirely through your Cloudflare Access policies.
### Troubleshooting
#### "No Access JWT present"
The request reached EmDash without an Access JWT. This means:
- Access isn't configured to protect your application
- The Access policy isn't matching the admin routes
Verify your Access application covers `/_emdash/admin/*`.
#### "JWT audience mismatch"
The `audience` in your config doesn't match the JWT. Double-check the Application Audience Tag in your Access application settings.
#### "User not authorized"
The user authenticated via Access but `autoProvision` is `false` and they don't exist in EmDash. Either:
- Set `autoProvision: true`, or
- Create the user manually before they log in

View File

@@ -0,0 +1,320 @@
---
title: Create a Blog
description: Build a complete blog with posts, categories, and tags using EmDash.
---
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
import postsListImg from "../../../assets/screenshots/admin-posts-list.png";
import postEditorImg from "../../../assets/screenshots/admin-post-editor.png";
This guide walks you through creating a blog with EmDash, from defining your content type to displaying posts with categories and tags.
## Prerequisites
- An EmDash site set up and running (see [Getting Started](/getting-started/))
- Basic familiarity with Astro components
## Define the Posts Collection
EmDash creates a default "posts" collection during setup. If you need to customize it, use the admin dashboard or API.
The default posts collection includes:
- `title` - Post title
- `slug` - URL-friendly identifier
- `content` - Rich text body
- `excerpt` - Short description
- `featured_image` - Header image (optional)
- `status` - draft, published, or scheduled
- `publishedAt` - Publication date (system field)
## Create Your First Post
1. Open the admin dashboard at `/_emdash/admin`
2. Click **Posts** in the sidebar
<img src={postsListImg.src} alt="EmDash posts list showing titles, status, and dates" />
3. Click **New Post**
<img src={postEditorImg.src} alt="EmDash post editor with title, content, and publish options" />
4. Enter a title and write your content using the rich text editor
5. Add categories and tags in the sidebar panel
6. Set the status to **Published**
7. Click **Save**
Your post is now live. No rebuild required.
## Display Posts on Your Site
### List All Posts
Create a page that displays all published posts:
```astro title="src/pages/blog/index.astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Sort by publication date, newest first
const sortedPosts = posts.sort(
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0)
);
---
<Base title="Blog">
<h1>Blog</h1>
<ul>
{sortedPosts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>
<h2>{post.data.title}</h2>
<p>{post.data.excerpt}</p>
<time datetime={post.data.publishedAt?.toISOString()}>
{post.data.publishedAt?.toLocaleDateString()}
</time>
</a>
</li>
))}
</ul>
</Base>
```
### Display a Single Post
Create a dynamic route for individual posts:
```astro title="src/pages/blog/[slug].astro"
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
---
<Base title={post.data.title}>
<article>
{post.data.featured_image && (
<img src={post.data.featured_image} alt="" />
)}
<h1>{post.data.title}</h1>
<time datetime={post.data.publishedAt?.toISOString()}>
{post.data.publishedAt?.toLocaleDateString()}
</time>
<PortableText value={post.data.content} />
</article>
</Base>
```
<Aside type="tip">
For server-rendered sites, remove `getStaticPaths` and the page will render on demand. EmDash's
live content collections work in both modes.
</Aside>
## Add Categories and Tags
EmDash includes built-in category and tag taxonomies. See [Taxonomies](/guides/taxonomies/) for details on creating and managing terms.
### Filter Posts by Category
```astro title="src/pages/category/[slug].astro"
---
import { getEmDashCollection, getTerm, getTaxonomyTerms } from "emdash";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const categories = await getTaxonomyTerms("category");
// Flatten hierarchical categories
const flatten = (terms) => terms.flatMap((t) => [t, ...flatten(t.children)]);
return flatten(categories).map((cat) => ({
params: { slug: cat.slug },
props: { category: cat },
}));
}
const { category } = Astro.props;
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
where: { category: category.slug },
});
---
<Base title={category.label}>
<h1>{category.label}</h1>
{category.description && <p>{category.description}</p>}
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
### Display Post Categories
Show categories on individual posts:
```astro title="src/components/PostMeta.astro"
---
import { getEntryTerms } from "emdash";
interface Props {
postId: string;
}
const { postId } = Astro.props;
const categories = await getEntryTerms("posts", postId, "category");
const tags = await getEntryTerms("posts", postId, "tag");
---
<div class="post-meta">
{categories.length > 0 && (
<div class="categories">
<span>Categories:</span>
{categories.map((cat) => (
<a href={`/category/${cat.slug}`}>{cat.label}</a>
))}
</div>
)}
{tags.length > 0 && (
<div class="tags">
<span>Tags:</span>
{tags.map((tag) => (
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}
</div>
)}
</div>
```
## Add Pagination
For blogs with many posts, add pagination:
```astro title="src/pages/blog/page/[page].astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../../../layouts/Base.astro";
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() {
const { entries: allPosts } = await getEmDashCollection("posts", {
status: "published",
});
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({
params: { page: String(i + 1) },
props: { currentPage: i + 1, totalPages },
}));
}
const { currentPage, totalPages } = Astro.props;
const { entries: allPosts } = await getEmDashCollection("posts", {
status: "published",
});
const sortedPosts = allPosts.sort(
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0)
);
const start = (currentPage - 1) * POSTS_PER_PAGE;
const posts = sortedPosts.slice(start, start + POSTS_PER_PAGE);
---
<Base title={`Blog - Page ${currentPage}`}>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
<nav>
{currentPage > 1 && (
<a href={`/blog/page/${currentPage - 1}`}>Previous</a>
)}
<span>Page {currentPage} of {totalPages}</span>
{currentPage < totalPages && (
<a href={`/blog/page/${currentPage + 1}`}>Next</a>
)}
</nav>
</Base>
```
## Add an RSS Feed
Create an RSS feed for your blog:
```ts title="src/pages/rss.xml.ts"
import rss from "@astrojs/rss";
import { getEmDashCollection } from "emdash";
export async function GET(context) {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return rss({
title: "My Blog",
description: "A blog built with EmDash",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.publishedAt,
description: post.data.excerpt,
link: `/blog/${post.data.slug}`,
})),
});
}
```
Install the RSS package if you haven't already:
```bash
npm install @astrojs/rss
```
## Next Steps
- [Working with Content](/guides/working-with-content/) - Learn CRUD operations in the admin
- [Media Library](/guides/media-library/) - Add images to your posts
- [Taxonomies](/guides/taxonomies/) - Create custom classification systems

View File

@@ -0,0 +1,329 @@
---
title: Internationalization (i18n)
description: Translate content into multiple languages with per-locale publishing, slugs, and automatic fallback.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash integrates with [Astro's built-in i18n routing](https://docs.astro.build/en/guides/internationalization/) to provide multilingual content management. Astro handles URL routing and locale detection; EmDash handles translated content storage and retrieval.
Each translation is a full, independent content entry with its own slug, status, and revision history. The French version of a post can be in draft while the English version is published.
## Configuration
Enable i18n by adding an `i18n` block to your Astro config. EmDash reads this configuration automatically — there is no separate locale setup in EmDash.
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: { fr: "en", es: "en" },
},
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
When `i18n` is not present in the Astro config, all i18n features are disabled and EmDash behaves as a single-language CMS.
## How Translations Work
EmDash uses a **row-per-locale** model. Each translation is its own row in the database with its own ID, slug, and status, linked to other translations via a shared `translation_group` identifier.
```
ec_posts:
id | slug | locale | translation_group | status
---------|-------------|--------|-------------------|----------
01ABC... | my-post | en | 01ABC... | published
01DEF... | mon-article | fr | 01ABC... | draft
01GHI... | mi-entrada | es | 01ABC... | published
```
This design means:
- **Per-locale slugs** — `/blog/my-post` and `/fr/blog/mon-article` work naturally
- **Per-locale publishing** — publish the English version while keeping French in draft
- **Per-locale revisions** — each translation has its own revision history
- **No cross-locale query complexity** — list queries return entries for one locale only
## Querying Translated Content
### Single entry
Pass `locale` to `getEmDashEntry` to retrieve a specific translation. When omitted, it defaults to the request's current locale (set by Astro's i18n middleware).
```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug, {
locale: Astro.currentLocale,
});
if (!post) return Astro.redirect("/404");
---
<article>
<h1>{post.data.title}</h1>
</article>
```
### Fallback chain
When no content exists for the requested locale, EmDash follows the fallback chain defined in your Astro config. Given `fallback: { fr: "en" }`:
1. Try the requested locale (`fr`)
2. Try the fallback locale (`en`)
3. Try the default locale
Fallback only applies to single-entry queries. List queries return entries for the requested locale only — no cross-locale mixing.
<Aside>
When a fallback is used, the response metadata includes `fallbackLocale` so your template can display a "this content is not yet translated" notice.
</Aside>
### Collection listing
Filter a collection by locale:
```astro title="src/pages/posts.astro"
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts", {
locale: Astro.currentLocale,
status: "published",
});
---
<ul>
{posts.map((post) => (
<li><a href={`/${post.data.slug}`}>{post.data.title}</a></li>
))}
</ul>
```
## Language Switcher
Use `getTranslations` to build a language switcher that links to existing translations of the current entry:
```astro title="src/components/LanguageSwitcher.astro"
---
import { getTranslations } from "emdash";
import { getRelativeLocaleUrl } from "astro:i18n";
interface Props {
collection: string;
entryId: string;
}
const { collection, entryId } = Astro.props;
const { translations } = await getTranslations(collection, entryId);
---
<nav aria-label="Language">
<ul>
{translations.map((t) => (
<li>
<a
href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
>
{t.locale.toUpperCase()}
</a>
</li>
))}
</ul>
</nav>
```
The `getTranslations` function returns all locale variants in the same translation group:
```ts
const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
// translations: [
// { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
// { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
// ]
```
<Aside type="tip">
Use `getRelativeLocaleUrl` from `astro:i18n` to build locale-prefixed URLs. This respects your Astro routing strategy (`prefix-other-locales` or `prefix-always`).
</Aside>
## Managing Translations in the Admin
### Content list
When i18n is enabled, the content list shows:
- A **locale column** displaying each entry's locale
- A **locale filter** in the toolbar to switch between locales
### Creating translations
Open any content entry in the editor. The sidebar displays a **Translations** panel listing all configured locales. For each locale:
- **"Translate"** appears for locales without a translation — click to create one
- **"Edit"** appears for locales with an existing translation — click to navigate to it
- The current locale is marked with a checkmark
When creating a translation, the new entry is pre-filled with data from the source locale and assigned a default slug of `{source-slug}-{locale}`. Adjust the slug and content as needed, then save.
### Per-locale publishing
Each translation has its own status. Publish, unpublish, or schedule translations independently. The French version can be in draft while the English version is live.
## Content API
### Locale parameter
All content API routes accept an optional `locale` query parameter:
```http
GET /_emdash/api/content/posts?locale=fr
GET /_emdash/api/content/posts/my-post?locale=fr
```
When omitted, defaults to the configured default locale.
### Creating translations via API
Create a translation by passing `locale` and `translationOf` to the content create endpoint:
```http
POST /_emdash/api/content/posts
Content-Type: application/json
{
"locale": "fr",
"translationOf": "01ABC...",
"data": {
"title": "Mon Article",
"slug": "mon-article"
}
}
```
The new entry shares the source entry's `translation_group` and starts as a draft.
### Listing translations
Retrieve all translations for a given entry:
```http
GET /_emdash/api/content/posts/01ABC.../translations
```
Returns the translation group ID and an array of locale variants with their IDs, slugs, and statuses.
## CLI
The CLI supports `--locale` flags on content commands:
```bash
# List French posts
emdash content list posts --locale fr
# Get a specific entry in French
emdash content get posts my-post --locale fr
# Create a French translation of an existing entry
emdash content create posts --locale fr --translation-of 01ABC...
```
## Seeding Multilingual Content
Seed files express translations using `locale` and `translationOf`:
```json title=".emdash/seed.json"
{
"content": {
"posts": [
{
"id": "welcome",
"slug": "welcome",
"locale": "en",
"status": "published",
"data": { "title": "Welcome" }
},
{
"id": "welcome-fr",
"slug": "bienvenue",
"locale": "fr",
"translationOf": "welcome",
"status": "draft",
"data": { "title": "Bienvenue" }
}
]
}
}
```
The source locale entry must appear before its translations in the seed file so that `translationOf` references resolve correctly.
## Field Translatability
Each field has a `translatable` setting (default: `true`). When creating a translation:
- **Translatable fields** are pre-filled from the source locale for editing
- **Non-translatable fields** are copied and kept in sync across all translations in the group
System fields like `status`, `published_at`, and `author_id` are always per-locale and never synced.
<Aside>
Non-translatable fields are useful for values that should stay consistent across locales, such as a product SKU or a sort order number.
</Aside>
## URL Strategy
EmDash does not manage locale URLs — Astro handles routing. Common patterns:
```
# prefix-other-locales (Astro default)
/blog/my-post → en (default locale, no prefix)
/fr/blog/mon-article → fr
# prefix-always
/en/blog/my-post → en
/fr/blog/mon-article → fr
```
Use `getRelativeLocaleUrl` from `astro:i18n` to build correct URLs regardless of routing mode.
## Importing Multilingual Content
### WordPress with WPML or Polylang
The WordPress plugin import source detects WPML and Polylang automatically. When detected, imported content includes locale and translation group metadata, preserving the multilingual structure.
### WXR files
WXR exports do not include WPML/Polylang metadata. Import as a single locale and create translations manually, or use the `--locale` flag to assign a locale to all imported items:
```bash
# Import a French WXR export
emdash import wordpress export-fr.xml --execute --locale fr
# Match against existing English content by slug
emdash import wordpress export-fr.xml --execute --locale fr --translation-of-locale en
```
## Next Steps
- [Querying Content](/guides/querying-content/) — Full query API reference
- [Working with Content](/guides/working-with-content/) — Admin content management
- [Astro i18n routing](https://docs.astro.build/en/guides/internationalization/) — Astro's routing configuration

View File

@@ -0,0 +1,487 @@
---
title: Media Library
description: Upload and manage images and files in EmDash.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
import mediaLibraryImg from "../../../assets/screenshots/admin-media-library.png";
EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, organizing, and using media in your content.
## Accessing the Media Library
Open the media library from the admin sidebar by clicking **Media**. The library displays all uploaded files with previews, filenames, and upload dates.
<img src={mediaLibraryImg.src} alt="EmDash media library showing image grid with upload button" />
## Uploading Files
### From the Media Library
1. Click **Media** in the admin sidebar
2. Click **Upload** or drag files onto the upload area
3. Select one or more files from your computer
4. Wait for uploads to complete
### From the Content Editor
1. In the rich text editor, click the image button
2. Click **Upload** in the media picker
3. Select a file from your computer
4. Add alt text and click **Insert**
## Supported File Types
EmDash supports common web file types:
| Category | Extensions |
| --------- | --------------------------------------------------------- |
| Images | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`, `.svg` |
| Documents | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` |
| Video | `.mp4`, `.webm`, `.mov` |
| Audio | `.mp3`, `.wav`, `.ogg` |
<Aside type="caution">
Maximum file size depends on your storage configuration. The default limit is 10MB per file.
</Aside>
## Storage Backends
EmDash supports multiple storage backends. Configure storage in your Astro config:
<Tabs>
<TabItem label="Local Storage">
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
});
```
Files are stored in the `./uploads` directory. Suitable for development and single-server deployments.
</TabItem>
<TabItem label="Cloudflare R2">
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { r2 } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
storage: r2({
binding: "MEDIA_BUCKET",
publicUrl: "https://media.example.com",
}),
}),
],
});
```
Requires an R2 bucket configured in `wrangler.jsonc`:
```jsonc title="wrangler.jsonc"
{
"r2_buckets": [
{
"binding": "MEDIA_BUCKET",
"bucket_name": "my-media-bucket",
},
],
}
```
</TabItem>
<TabItem label="S3-Compatible">
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { s3 } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
storage: s3({
endpoint: "https://s3.amazonaws.com",
bucket: "my-media-bucket",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
region: "us-east-1",
publicUrl: "https://media.example.com",
}),
}),
],
});
```
Works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
</TabItem>
</Tabs>
## How Uploads Work
EmDash uses signed URLs for secure uploads:
1. Client requests an upload URL from the API
2. Server generates a signed URL with expiration
3. Client uploads directly to storage using the signed URL
4. Server records the file metadata in the database
This approach keeps large files off your application server and enables direct uploads to cloud storage.
<Aside>
R2 bindings do not support pre-signed URLs. When using the R2 binding adapter, uploads go through
your Worker.
</Aside>
## Organizing Media
### Folders
Create folders to organize your media:
1. Click **New Folder** in the media library
2. Enter a folder name
3. Click **Create**
4. Drag files into folders to organize them
### Search
Use the search box to find files by name. Search matches partial filenames.
### Filters
Filter media by:
- **Type** - Images, Documents, Video, Audio
- **Date** - Upload date range
- **Folder** - Specific folder
## Using Media in Content
### In the Rich Text Editor
1. Place your cursor where you want the image
2. Click the image button in the toolbar
3. Select an image from the media library or upload a new one
4. Enter alt text
5. Click **Insert**
### As a Featured Image
1. Open a content entry in the editor
2. Find the **Featured Image** field in the sidebar
3. Click **Select Image**
4. Choose from the media library or upload
5. Click **Save**
### In Custom Fields
For fields configured as image or file types, click the field to open the media picker.
## Displaying Media in Templates
Access media URLs from your content data:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
---
{post?.data.featured_image && (
<img
src={post.data.featured_image}
alt={post.data.featured_image_alt ?? ""}
/>
)}
```
### Responsive Images
For responsive images, use Astro's Image component with external URLs:
### Responsive Images
For responsive images, use Astro's Image component with external URLs:
```astro
---
import { Image } from "astro:assets";
import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
---
{post?.data.featured_image && (
<Image
src={post.data.featured_image}
alt={post.data.featured_image_alt ?? ""}
width={800}
height={450}
/>
)}
```
<Aside type="tip">
Configure allowed image domains in `astro.config.mjs` to use the Image component with external URLs:
```js
export default defineConfig({
image: {
domains: ["media.example.com"],
},
});
```
</Aside>
## Deleting Media
1. Select the file(s) you want to delete
2. Click **Delete**
3. Confirm the deletion
<Aside type="caution">
Deleting media does not remove references in your content. Ensure you update or remove content
that uses deleted files.
</Aside>
## Media API
Access media programmatically using the admin API.
### Upload a File
Upload media as multipart form data:
```bash
POST /_emdash/api/media
Content-Type: multipart/form-data
Authorization: Bearer YOUR_API_TOKEN
file=<binary file data>
```
Response:
```json
{
"success": true,
"data": {
"item": {
"id": "01ABC123",
"filename": "hero-image.jpg",
"mime_type": "image/jpeg",
"storage_key": "media/abc123/hero-image.jpg",
"width": 1200,
"height": 800
}
}
}
```
### List Media
```bash
GET /_emdash/api/media?prefix=images/&limit=20
Authorization: Bearer YOUR_API_TOKEN
```
### Delete Media
```bash
DELETE /_emdash/api/media/images/hero.jpg
Authorization: Bearer YOUR_API_TOKEN
```
## Media Providers
In addition to local storage, EmDash supports external media providers for specialized image and video hosting. Media providers appear as tabs in the media picker, letting editors choose from multiple sources.
### Available Providers
<Tabs>
<TabItem label="Cloudflare Images">
[Cloudflare Images](https://developers.cloudflare.com/images/) provides image hosting with automatic optimization, resizing, and format conversion.
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { cloudflareImages } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
// ... database, storage config
mediaProviders: [
cloudflareImages({
accountId: import.meta.env.CF_ACCOUNT_ID,
apiToken: import.meta.env.CF_IMAGES_TOKEN,
// Optional: custom delivery domain
deliveryDomain: "images.example.com",
}),
],
}),
],
});
```
**Features:**
- Browse and upload images directly from the admin
- Automatic image optimization and format conversion
- URL-based transformations (resize, crop, format)
- Flexible variants for responsive images
</TabItem>
<TabItem label="Cloudflare Stream">
[Cloudflare Stream](https://developers.cloudflare.com/stream/) provides video hosting with HLS/DASH adaptive streaming.
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { cloudflareStream } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
// ... database, storage config
mediaProviders: [
cloudflareStream({
accountId: import.meta.env.CF_ACCOUNT_ID,
apiToken: import.meta.env.CF_STREAM_TOKEN,
// Optional: player settings
controls: true,
autoplay: false,
loop: false,
}),
],
}),
],
});
```
**Features:**
- Browse, search, and upload videos from the admin
- HLS and DASH adaptive streaming
- Automatic thumbnail generation
- Direct upload for large files
</TabItem>
</Tabs>
### Using Multiple Providers
You can configure multiple providers. Each appears as a tab in the media picker:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { cloudflareImages, cloudflareStream } from "@emdash-cms/cloudflare";
export default defineConfig({
integrations: [
emdash({
database: d1({ binding: "DB" }),
storage: r2({ binding: "MEDIA" }),
mediaProviders: [
cloudflareImages({
accountId: import.meta.env.CF_ACCOUNT_ID,
apiToken: import.meta.env.CF_IMAGES_TOKEN,
}),
cloudflareStream({
accountId: import.meta.env.CF_ACCOUNT_ID,
apiToken: import.meta.env.CF_STREAM_TOKEN,
}),
],
}),
],
});
```
The local media library ("Library" tab) is always available alongside any configured providers.
### Rendering Provider Media
Use the `Image` component to render media:
```astro title="src/pages/posts/[slug].astro"
---
import { Image } from "emdash/ui";
import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
---
{post?.data.featured_image && (
<Image
image={post.data.featured_image}
width={800}
height={450}
/>
)}
```
The component automatically:
- Detects the provider from the stored value
- Renders an optimized `<img>` element
- Applies provider-specific optimizations (e.g., Cloudflare Images transformations)
### MediaValue Type
Media fields store a `MediaValue` object containing provider information:
```ts
interface MediaValue {
provider?: string; // Provider ID, defaults to "local"
id: string; // Provider-specific ID
src?: string; // Direct URL (for local media or legacy data)
previewUrl?: string; // Preview URL for admin display (external providers)
filename?: string; // Original filename
mimeType?: string; // MIME type
width?: number; // Image/video width
height?: number; // Image/video height
alt?: string; // Alt text
meta?: Record<string, unknown>; // Provider-specific metadata
}
```
This allows EmDash to render media correctly regardless of where it's hosted.
## Next Steps
- [Working with Content](/guides/working-with-content/) - Use media in your content
- [Create a Blog](/guides/create-a-blog/) - Add images to blog posts
- [Querying Content](/guides/querying-content/) - Display media in templates

View File

@@ -0,0 +1,281 @@
---
title: Navigation Menus
description: Create and manage navigation menus for headers, footers, and sidebars.
---
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
EmDash menus are ordered lists of links that you manage through the admin interface. Menus support nesting for dropdowns and can link to pages, posts, taxonomy terms, or external URLs.
## Querying Menus
Use `getMenu()` to fetch a menu by its unique name:
```astro title="src/layouts/Base.astro"
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
{primaryMenu && (
<nav>
<ul>
{primaryMenu.items.map(item => (
<li>
<a href={item.url}>{item.label}</a>
</li>
))}
</ul>
</nav>
)}
```
The function returns `null` if no menu exists with that name.
## Menu Structure
A menu contains metadata and an array of items:
```ts
interface Menu {
id: string;
name: string; // Unique identifier ("primary", "footer")
label: string; // Display name ("Primary Navigation")
items: MenuItem[];
}
interface MenuItem {
id: string;
label: string;
url: string; // Resolved URL
target?: string; // "_blank" for new window
titleAttr?: string; // HTML title attribute
cssClasses?: string; // Custom CSS classes
children: MenuItem[]; // Nested items for dropdowns
}
```
URLs are resolved automatically based on the item type:
- **Page/Post items** resolve to `/{collection}/{slug}`
- **Taxonomy items** resolve to `/{taxonomy}/{slug}`
- **Collection items** resolve to `/{collection}/`
- **Custom links** use the URL as-is
## Rendering Nested Menus
Menu items can have children for dropdown navigation. Handle nesting by recursively rendering the `children` array:
```astro title="src/components/Navigation.astro"
---
import { getMenu } from "emdash";
import type { MenuItem } from "emdash";
interface Props {
name: string;
}
const menu = await getMenu(Astro.props.name);
---
{menu && (
<nav class="nav">
<ul class="nav-list">
{menu.items.map(item => (
<li class:list={["nav-item", item.cssClasses]}>
<a
href={item.url}
target={item.target}
title={item.titleAttr}
aria-current={Astro.url.pathname === item.url ? "page" : undefined}
>
{item.label}
</a>
{item.children.length > 0 && (
<ul class="submenu">
{item.children.map(child => (
<li>
<a href={child.url} target={child.target}>
{child.label}
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}
```
<Aside>
Use `aria-current="page"` to indicate the current page in navigation. Screen readers announce
this, and the `[aria-current="page"]` CSS selector enables styling the active link.
</Aside>
## Menu Item Types
The admin supports five types of menu items:
| Type | Description | URL Resolution |
| ------------ | ---------------------------- | ---------------------- |
| `page` | Link to a page | `/{collection}/{slug}` |
| `post` | Link to a post | `/{collection}/{slug}` |
| `taxonomy` | Link to a category or tag | `/{taxonomy}/{slug}` |
| `collection` | Link to a collection archive | `/{collection}/` |
| `custom` | External or custom URL | Used as-is |
## Listing All Menus
Use `getMenus()` to retrieve all menu definitions (without items):
```ts
import { getMenus } from "emdash";
const menus = await getMenus();
// Returns: [{ id, name, label }, ...]
```
This is primarily useful for admin interfaces or debugging.
## Creating Menus
Create menus through the admin interface at `/_emdash/admin/menus`, or use the admin API:
```http
POST /_emdash/api/menus
Content-Type: application/json
{
"name": "footer",
"label": "Footer Navigation"
}
```
Add items to a menu:
```http
POST /_emdash/api/menus/footer/items
Content-Type: application/json
{
"type": "page",
"referenceCollection": "pages",
"referenceId": "page_privacy",
"label": "Privacy Policy"
}
```
Add a custom external link:
```http
POST /_emdash/api/menus/footer/items
Content-Type: application/json
{
"type": "custom",
"customUrl": "https://github.com/example",
"label": "GitHub",
"target": "_blank"
}
```
## Reordering and Nesting
Update item order and parent-child relationships with the reorder endpoint:
```http
POST /_emdash/api/menus/primary/reorder
Content-Type: application/json
{
"items": [
{ "id": "item_1", "parentId": null, "sortOrder": 0 },
{ "id": "item_2", "parentId": null, "sortOrder": 1 },
{ "id": "item_3", "parentId": "item_2", "sortOrder": 0 }
]
}
```
This makes `item_3` a child of `item_2`, creating a dropdown.
## Complete Example
The following example shows a responsive header with primary navigation:
```astro title="src/layouts/Base.astro"
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<html lang="en">
<head>
<title>{settings.title}</title>
</head>
<body>
<header class="header">
<a href="/" class="logo">
{settings.logo ? (
<img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
) : (
settings.title
)}
</a>
{primaryMenu && (
<nav class="main-nav" aria-label="Main navigation">
<ul>
{primaryMenu.items.map(item => (
<li class:list={[item.cssClasses, { "has-children": item.children.length > 0 }]}>
<a
href={item.url}
target={item.target}
aria-current={Astro.url.pathname === item.url ? "page" : undefined}
>
{item.label}
</a>
{item.children.length > 0 && (
<ul class="dropdown">
{item.children.map(child => (
<li>
<a href={child.url} target={child.target}>{child.label}</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}
</header>
<main>
<slot />
</main>
</body>
</html>
```
## API Reference
### `getMenu(name)`
Fetch a menu by name with all items and resolved URLs.
**Parameters:**
- `name` — The menu's unique identifier (string)
**Returns:** `Promise<Menu | null>`
### `getMenus()`
List all menu definitions without items.
**Returns:** `Promise<Array<{ id: string; name: string; label: string }>>`

View File

@@ -0,0 +1,144 @@
---
title: Page Layouts
description: Let editors choose different layouts for individual pages using a template field.
---
import { Aside } from '@astrojs/starlight/components';
WordPress has "Page Templates" — a dropdown in the editor that lets you pick a layout per page (e.g. Default, Full Width, Landing Page). EmDash supports the same pattern using a `select` field.
## How it works
1. Add a `template` select field to your pages collection
2. Create layout components for each option
3. Map the field value to a layout in your page route
No special system is needed — this uses EmDash's existing select field and Astro's component model.
## Add the field
In the admin UI, add a select field to your pages collection with slug `template` and your layout options (e.g. "Default", "Full Width"). Or include it in your seed data:
```json title=".emdash/seed.json"
{
"slug": "template",
"label": "Template",
"type": "select",
"validation": {
"options": ["Default", "Full Width"]
},
"defaultValue": "Default"
}
```
## Create layout components
Each layout wraps content in your base layout with different styling:
```astro title="src/layouts/PageDefault.astro"
---
import type { ContentEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "./Base.astro";
interface Props {
page: ContentEntry<any>;
}
const { page } = Astro.props;
---
<Base title={page.data.title}>
<article class="page-default">
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
<style>
.page-default {
max-width: var(--content-width);
margin: 0 auto;
padding: 2rem 1rem;
}
</style>
```
```astro title="src/layouts/PageFullWidth.astro"
---
import type { ContentEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "./Base.astro";
interface Props {
page: ContentEntry<any>;
}
const { page } = Astro.props;
---
<Base title={page.data.title}>
<article class="page-wide">
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
<style>
.page-wide {
max-width: var(--wide-width);
margin: 0 auto;
padding: 2rem 1rem;
}
</style>
```
## Wire up the route
In your page route, import each layout and map the template value:
```astro title="src/pages/pages/[slug].astro"
---
import { getEmDashEntry } from "emdash";
import PageDefault from "../../layouts/PageDefault.astro";
import PageFullWidth from "../../layouts/PageFullWidth.astro";
const { slug } = Astro.params;
if (!slug) {
return Astro.redirect("/404");
}
const { entry: page } = await getEmDashEntry("pages", slug);
if (!page) {
return Astro.redirect("/404");
}
const layouts = {
"Default": PageDefault,
"Full Width": PageFullWidth,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
```
The route stays small. Each layout component owns its own markup and styling. Adding a layout is: create a component, add the option to the select field, add a line to the map.
<Aside type="tip">
Use human-readable option names like "Full Width" rather than slugs like "full-width". The value is both the stored value and the admin dropdown label.
</Aside>
## Adding more layouts
Common choices from WordPress themes:
- **Default** — narrow content column, good for reading
- **Full Width** — wider content area, no sidebar
- **Landing Page** — no header/footer, hero sections
- **Sidebar** — content with a sidebar widget area
Each is just another Astro component in your `src/layouts/` directory and another entry in the route's layout map.

View File

@@ -0,0 +1,384 @@
---
title: Preview Mode
description: Enable secure previews of draft content before publishing.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash's preview system lets editors view unpublished content through secure, time-limited URLs. Preview links use HMAC-SHA256 signed tokens that you can share with reviewers without exposing your entire draft content.
## How It Works
1. The admin generates a preview URL for a draft post
2. The URL contains a signed `_preview` query parameter with an expiration time
3. EmDash's middleware automatically verifies the token and sets up the request context
4. Your template code calls `getEmDashEntry()` as normal — draft content is served automatically
Preview is **implicit**. Your template code doesn't need to handle tokens or pass preview options — the middleware and query functions handle everything via `AsyncLocalStorage`.
## Setting Up Preview
Preview works out of the box — EmDash auto-generates a per-site preview
secret on first use and stores it in the database. No env vars required
for the common case.
Set `EMDASH_PREVIEW_SECRET` in your environment only if you need to:
- Share the secret across multiple processes (e.g. a separate preview
Worker that signs URLs and sends them to your main site for verification)
- Pin the secret to a value you control for compliance/audit reasons
- Migrate to a known value when restoring from a backup
```bash title=".env"
# Optional: override the auto-generated secret
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"
```
If set, the env value wins over the DB-stored value.
That's it. Your existing templates work with preview automatically:
```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
// No special preview handling needed — the middleware
// detects _preview tokens and serves draft content automatically
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!entry) {
return Astro.redirect("/404");
}
---
{isPreview && (
<div class="preview-banner">
You are viewing a preview. This content is not published.
</div>
)}
<article>
<h1>{entry.data.title}</h1>
</article>
```
The `isPreview` flag is `true` when draft content is being served via a valid preview token.
## Generating Preview URLs
Use `getPreviewUrl()` to create preview links. The function takes the
secret as an explicit argument:
```ts
import { getPreviewUrl } from "emdash";
const previewUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
expiresIn: "1h",
});
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...
```
When `EMDASH_PREVIEW_SECRET` isn't set, EmDash auto-generates and stores
a per-site secret in the database for token verification. The
`getPreviewUrl()` template helper still requires you to pass the secret
explicitly — pin your env var if you call it from page templates. Most
sites use the admin UI's "Generate preview link" button instead, which
goes through the API and uses the resolved secret automatically.
With a base URL for absolute links:
```ts
const fullUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
baseUrl: "https://example.com",
});
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...
```
With a custom path pattern:
```ts
const blogUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
pathPattern: "/blog/{id}",
});
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...
```
### Locale-aware paths
`pathPattern` also supports a `{locale}` placeholder. Pass an empty `locale`
when the entry is in the default locale and `prefixDefaultLocale` is `false`;
adjacent slashes left by the empty value are collapsed automatically.
```ts
await getPreviewUrl({
collection: "posts",
id: "hello",
secret,
pathPattern: "/{locale}/{id}",
locale: "pt-br",
});
// Returns: /pt-br/hello?_preview=...
await getPreviewUrl({
collection: "posts",
id: "hello",
secret,
pathPattern: "/{locale}/{id}",
locale: "", // default locale, no prefix
});
// Returns: /hello?_preview=...
```
The admin's "View on site" link goes through `POST /_emdash/api/content/{collection}/{id}/preview-url`,
which reads the entry's locale, looks up the site's i18n config and supplies
the `locale` automatically. To change the default pattern used by that
endpoint, set `EMDASH_PREVIEW_PATH_PATTERN` (e.g. `/{locale}/{id}`) — request
bodies still win when they include their own `pathPattern`.
## Token Expiration
Control how long preview links remain valid:
```ts
// Valid for 1 hour (default)
await getPreviewUrl({ ..., expiresIn: "1h" });
// Valid for 30 minutes
await getPreviewUrl({ ..., expiresIn: "30m" });
// Valid for 1 day
await getPreviewUrl({ ..., expiresIn: "1d" });
// Valid for 2 weeks
await getPreviewUrl({ ..., expiresIn: "2w" });
// Valid for 3600 seconds
await getPreviewUrl({ ..., expiresIn: 3600 });
```
Supported units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).
## Verifying Tokens
Use `verifyPreviewToken()` to validate incoming preview requests:
```ts
import { verifyPreviewToken } from "emdash";
// From a URL (extracts _preview query parameter)
const result = await verifyPreviewToken({
url: Astro.url,
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});
// Or with a token directly
const result = await verifyPreviewToken({
token: someTokenString,
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
});
```
The result indicates whether the token is valid:
```ts
if (result.valid) {
// Token is valid
console.log(result.payload.cid); // "posts:my-draft-post"
console.log(result.payload.exp); // Expiry timestamp
console.log(result.payload.iat); // Issued-at timestamp
} else {
// Token is invalid
console.log(result.error);
// "none" - no token present
// "malformed" - token structure is invalid
// "invalid" - signature verification failed
// "expired" - token has expired
}
```
## Preview Indicator
You can show a visual indicator when content is being previewed. The `isPreview` flag returned by `getEmDashEntry` tells you when draft content is being served:
```astro
{isPreview && (
<div class="preview-banner" role="alert">
<strong>Preview</strong> — You are viewing unpublished content.
<a href={Astro.url.pathname}>Exit preview</a>
</div>
)}
```
<Aside type="tip">
For authenticated editors using visual editing, EmDash automatically injects a floating toolbar
that indicates edit/preview mode. You only need a custom preview banner for shared preview links.
</Aside>
## Helper Functions
### `isPreviewRequest(url)`
Check if a URL contains a preview token:
```ts
import { isPreviewRequest } from "emdash";
if (isPreviewRequest(Astro.url)) {
// Handle preview request
}
```
### `getPreviewToken(url)`
Extract the token string from a URL:
```ts
import { getPreviewToken } from "emdash";
const token = getPreviewToken(Astro.url);
// Returns the token string or null
```
### `parseContentId(contentId)`
Parse a content ID into collection and ID:
```ts
import { parseContentId } from "emdash";
const { collection, id } = parseContentId("posts:my-draft-post");
// { collection: "posts", id: "my-draft-post" }
```
## Token Format
Preview tokens use a compact format: `base64url(payload).base64url(signature)`
The payload contains:
- `cid` — Content ID in format `collection:id`
- `exp` — Expiry timestamp (seconds since epoch)
- `iat` — Issued-at timestamp (seconds since epoch)
Tokens are signed with HMAC-SHA256 using your preview secret.
<Aside type="caution">
Keep your `EMDASH_PREVIEW_SECRET` secure. Anyone with this secret can generate valid preview tokens for
any content.
</Aside>
## Complete Example
A full blog post page with preview and visual editing support:
```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import BaseLayout from "../../layouts/Base.astro";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params;
// Preview is automatic — middleware handles token verification
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!entry) {
return Astro.redirect("/404");
}
---
<BaseLayout title={entry.data.title}>
{isPreview && (
<div class="preview-banner" role="alert">
<strong>Preview</strong> — This content is not published.
</div>
)}
<article {...entry.edit}>
<header>
<h1 {...entry.edit.title}>{entry.data.title}</h1>
{entry.data.publishedAt && (
<time datetime={entry.data.publishedAt.toISOString()}>
{entry.data.publishedAt.toLocaleDateString()}
</time>
)}
{isPreview && !entry.data.publishedAt && (
<span class="draft-indicator">Draft</span>
)}
</header>
<div class="content" {...entry.edit.content}>
<PortableText value={entry.data.content} />
</div>
</article>
</BaseLayout>
```
Note the `{...entry.edit}` and `{...entry.edit.title}` spreads — these add `data-emdash-ref` attributes that enable visual editing for authenticated editors. In production, they produce no output.
## API Reference
### `getPreviewUrl(options)`
Generate a preview URL with a signed token.
**Options:**
- `collection` — Collection slug (string)
- `id` — Content ID or slug (string)
- `secret` — Signing secret (string)
- `expiresIn` — Token validity duration (default: `"1h"`)
- `baseUrl` — Optional base URL for absolute links
- `pathPattern` — URL pattern with `{collection}`, `{id}` and `{locale}` placeholders (default: `"/{collection}/{id}"`)
- `locale` — Value substituted for `{locale}`. Empty string omits the locale segment (slashes are collapsed).
**Returns:** `Promise<string>`
### `verifyPreviewToken(options)`
Verify a preview token.
**Options:**
- `secret` — Verification secret (string)
- `url` — URL to extract token from, OR
- `token` — Token string directly
**Returns:** `Promise<VerifyPreviewTokenResult>`
```ts
type VerifyPreviewTokenResult =
| { valid: true; payload: PreviewTokenPayload }
| { valid: false; error: "invalid" | "expired" | "malformed" | "none" };
```
### `generatePreviewToken(options)`
Generate a token without building a URL.
**Options:**
- `contentId` — Content ID in format `collection:id`
- `expiresIn` — Token validity duration (default: `"1h"`)
- `secret` — Signing secret
**Returns:** `Promise<string>`

View File

@@ -0,0 +1,411 @@
---
title: Querying Content
description: Use getEmDashCollection and getEmDashEntry to retrieve content in your templates.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash provides query functions to retrieve content in your Astro pages and components. These functions follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning structured results with error handling.
## Query Functions
EmDash exports two primary query functions:
| Function | Purpose | Returns |
| ----------------------- | -------------------------------------- | ----------------------------- |
| `getEmDashCollection` | Retrieve all entries of a content type | `{ entries, error }` |
| `getEmDashEntry` | Retrieve a single entry by ID or slug | `{ entry, error, isPreview }` |
Import them from `emdash`:
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
```
## Get All Entries
Use `getEmDashCollection` to retrieve all entries of a content type:
```astro title="src/pages/posts.astro"
---
import { getEmDashCollection } from "emdash";
const { entries: posts, error } = await getEmDashCollection("posts");
if (error) {
console.error("Failed to load posts:", error);
}
---
<ul>
{posts.map((post) => (
<li>{post.data.title}</li>
))}
</ul>
```
<Aside>
The destructuring syntax `{ entries: posts }` renames `entries` to `posts` for cleaner code. Using `{ entries }` directly also works.
</Aside>
### Filter by Locale
When [i18n is enabled](/guides/internationalization/), filter by locale to retrieve content in a specific language:
```ts
// French posts
const { entries: frenchPosts } = await getEmDashCollection("posts", {
locale: "fr",
status: "published",
});
// Use the current request locale
const { entries: localizedPosts } = await getEmDashCollection("posts", {
locale: Astro.currentLocale,
status: "published",
});
```
For single entries, pass `locale` as the third argument:
```ts
const { entry: post } = await getEmDashEntry("posts", "my-post", {
locale: Astro.currentLocale,
});
```
When `locale` is omitted, it defaults to the request's current locale. If no translation exists for the requested locale, the [fallback chain](/guides/internationalization/#fallback-chain) is followed.
### Filter by Status
Retrieve only published or draft content:
```ts
// Only published posts
const { entries: published } = await getEmDashCollection("posts", {
status: "published",
});
// Only drafts
const { entries: drafts } = await getEmDashCollection("posts", {
status: "draft",
});
```
<Aside type="tip">
Always filter by `status: "published"` for public-facing pages. Draft content should only be
accessible in the admin or preview mode.
</Aside>
### Limit Results
Restrict the number of returned entries:
```ts
// Get the 5 most recent posts
const { entries: recentPosts } = await getEmDashCollection("posts", {
status: "published",
limit: 5,
});
```
### Filter by Taxonomy
Filter entries by category, tag, or custom taxonomy terms:
```ts
// Posts in the "news" category
const { entries: newsPosts } = await getEmDashCollection("posts", {
status: "published",
where: { category: "news" },
});
// Posts with the "javascript" tag
const { entries: jsPosts } = await getEmDashCollection("posts", {
status: "published",
where: { tag: "javascript" },
});
// Posts matching any of multiple terms
const { entries: featuredNews } = await getEmDashCollection("posts", {
status: "published",
where: { category: ["news", "featured"] },
});
```
The `where` filter uses OR logic when multiple values are provided for a single taxonomy.
### Error Handling
Always check for errors when reliability matters:
```ts
const { entries: posts, error } = await getEmDashCollection("posts");
if (error) {
// Log and handle gracefully
console.error("Failed to load posts:", error);
return new Response("Server error", { status: 500 });
}
```
## Get a Single Entry
Use `getEmDashEntry` to retrieve one entry by its ID or slug:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return Astro.redirect("/404");
}
---
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>
```
### Entry Return Type
`getEmDashEntry` returns a result object:
```ts
interface EntryResult<T> {
entry: ContentEntry<T> | null; // null if not found
error?: Error; // Only set for actual errors (not "not found")
isPreview: boolean; // true if viewing preview/draft content
}
interface ContentEntry<T> {
id: string;
data: T;
edit: EditProxy; // Visual editing annotations
}
```
The `data` object within `entry` contains all fields defined for the content type. The `edit` proxy provides visual editing annotations (see below).
## Preview Mode
EmDash handles preview automatically via middleware. When a URL contains a valid `_preview` token, the middleware verifies it and sets up the request context. Your query functions then serve draft content without any special parameters:
```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
// No special preview handling needed — middleware does it automatically
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!entry) {
return Astro.redirect("/404");
}
---
{isPreview && (
<div class="preview-banner">
Viewing preview. This content is not published.
</div>
)}
<article>
<h1>{entry.data.title}</h1>
<PortableText value={entry.data.content} />
</article>
```
<Aside>
Preview works out of the box — EmDash auto-generates a per-site preview secret on first
use. Set `EMDASH_PREVIEW_SECRET` in your environment if you need to share the secret across
multiple processes (e.g. a separate preview Worker). The admin generates preview URLs that
include an HMAC-signed `_preview` token either way.
</Aside>
## Visual Editing
Every entry returned by query functions includes an `edit` proxy for annotating your templates. Spread it onto elements to enable inline editing for authenticated editors:
```astro
<article {...entry.edit}>
<h1 {...entry.edit.title}>{entry.data.title}</h1>
<div {...entry.edit.content}>
<PortableText value={entry.data.content} />
</div>
</article>
```
In edit mode, `{...entry.edit.title}` produces a `data-emdash-ref` attribute that the visual editing toolbar uses to enable inline editing. In production, the proxy spreads produce no output — zero runtime cost.
<Aside type="tip">
For string and text fields, inline editing uses `contenteditable`. For Portable Text fields, it
injects a TipTap editor. For image fields, it opens a media library popover.
</Aside>
## Sorting Results
`getEmDashCollection` does not guarantee sort order. Sort results in your template:
```ts
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Sort by publication date, newest first
const sorted = posts.sort(
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0),
);
```
### Common Sort Patterns
```ts
// Alphabetical by title
posts.sort((a, b) => a.data.title.localeCompare(b.data.title));
// By custom order field
posts.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
// Random order
posts.sort(() => Math.random() - 0.5);
```
## TypeScript Types
Generate TypeScript types for your collections:
```bash
npx emdash types
```
This creates `.emdash/types.ts` with interfaces for each collection. Use them for type safety:
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
import type { Post } from "../.emdash/types";
// Type-safe collection query
const { entries: posts } = await getEmDashCollection<Post>("posts");
// posts is ContentEntry<Post>[]
// Type-safe entry query
const { entry: post } = await getEmDashEntry<Post>("posts", "my-post");
// post is ContentEntry<Post> | null
```
## Static vs. Server Rendering
EmDash content works with both static and server-rendered pages.
### Static (Prerendered)
For static pages, use `getStaticPaths` to generate routes at build time:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
---
```
### Server-Rendered
For server-rendered pages, query content directly:
```astro title="src/pages/posts/[slug].astro"
---
export const prerender = false;
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return new Response(null, { status: 404 });
}
---
```
<Aside type="tip">
Server rendering shows content changes immediately without rebuilding. Use it for frequently
updated content.
</Aside>
## Performance Considerations
### Caching
EmDash uses Astro's live content collections, which handle caching automatically. For server-rendered pages, consider adding HTTP cache headers:
```astro
---
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Cache for 5 minutes
Astro.response.headers.set("Cache-Control", "public, max-age=300");
---
```
### Avoid Redundant Queries
Query once and pass data to components:
```astro title="src/pages/index.astro"
---
import { getEmDashCollection } from "emdash";
import PostList from "../components/PostList.astro";
import Sidebar from "../components/Sidebar.astro";
// Query once
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
const featured = posts.filter((p) => p.data.featured);
const recent = posts.slice(0, 5);
---
<PostList posts={featured} />
<Sidebar posts={recent} />
```
## Next Steps
- [Create a Blog](/guides/create-a-blog/) - Build a complete blog
- [Taxonomies](/guides/taxonomies/) - Filter by categories and tags
- [Working with Content](/guides/working-with-content/) - Admin CRUD operations
- [Internationalization](/guides/internationalization/) - Multilingual content and translations

View File

@@ -0,0 +1,264 @@
---
title: Sections
description: Create and use reusable content blocks across your site.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Sections are reusable content blocks that editors can insert into any content via slash commands. Use them for common patterns like CTAs, testimonials, feature grids, or any content that appears across multiple pages.
## Querying Sections
### getSection
Fetch a single section by slug:
```typescript
import { getSection } from "emdash";
const cta = await getSection("newsletter-cta");
if (cta) {
console.log(cta.title); // "Newsletter CTA"
console.log(cta.content); // PortableTextBlock[]
}
```
### getSections
Fetch multiple sections with optional filters:
```typescript
import { getSections } from "emdash";
// Get all sections
const { items: all } = await getSections();
// Filter by source
const { items: themeSections } = await getSections({ source: "theme" });
// Search by title/keywords
const { items: results } = await getSections({ search: "newsletter" });
```
`getSections` returns `{ items: Section[], nextCursor?: string }` following the standard pagination pattern.
## Section Structure
```typescript
interface Section {
id: string;
slug: string;
title: string;
description?: string;
keywords: string[];
content: PortableTextBlock[];
previewUrl?: string;
source: "theme" | "user" | "import";
themeId?: string;
createdAt: string;
updatedAt: string;
}
```
### Source Types
| Source | Description |
| -------- | ------------------------------------------------ |
| `theme` | Defined in seed file, managed by theme |
| `user` | Created by editors in admin |
| `import` | Imported from WordPress (reusable blocks) |
## Using Sections in Content
Editors insert sections using the `/section` slash command in the rich text editor:
<Steps>
1. Type `/section` (or `/pattern`, `/block`, `/template`)
2. Search or browse available sections
3. Click to insert the section's content at the cursor position
</Steps>
The section's Portable Text content is copied into the document. This means:
- Changes to the section don't affect already-inserted content
- Editors can customize the inserted content
- Content remains self-contained
<Aside type="tip">
For content that should stay synchronized, consider using [Widget Areas](/guides/widgets/) with component widgets instead.
</Aside>
## Creating Sections
### In the Admin UI
1. Navigate to **Sections** in the admin sidebar
2. Click **New Section**
3. Fill in:
- **Title** - Display name for the section
- **Slug** - URL identifier (auto-generated from title)
- **Description** - Help text for editors
4. Add content using the rich text editor
5. Optionally set keywords for easier discovery
### Via Seed Files
Include sections in your theme's seed file:
```json
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA",
"keywords": ["hero", "banner", "header"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [{ "_type": "span", "text": "Your tagline goes here." }]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter CTA",
"keywords": ["newsletter", "subscribe", "email"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
}
]
}
]
}
```
### Via WordPress Import
WordPress reusable blocks (`wp_block` post type) are automatically imported as sections:
- Source is set to `"import"`
- Gutenberg content converted to Portable Text
## Rendering Sections Programmatically
For server-rendered section content outside the editor:
```astro
---
import { getSection } from "emdash";
import { PortableText } from "emdash/ui";
const newsletter = await getSection("newsletter-cta");
---
{newsletter && (
<aside class="cta-box">
<PortableText value={newsletter.content} />
</aside>
)}
```
## Admin UI Features
The Sections library (`/_emdash/admin/sections`) provides:
- **Grid view** with section previews
- **Search** by title and keywords
- **Filter** by source
- **Quick copy** slug to clipboard
- **Edit** section content and metadata
- **Delete** with confirmation (warns for theme sections)
## API Reference
### `getSection(slug)`
Fetch a section by slug.
**Parameters:**
- `slug` — The section's unique identifier (string)
**Returns:** `Promise<Section | null>`
### `getSections(options?)`
List sections with optional filters.
**Parameters:**
- `options.source` — Filter by source: `"theme"`, `"user"`, or `"import"`
- `options.search` — Search title and keywords
**Returns:** `Promise<Section[]>`
## REST API
### List Sections
```http
GET /_emdash/api/sections
GET /_emdash/api/sections?source=theme
GET /_emdash/api/sections?search=newsletter
```
### Get Section
```http
GET /_emdash/api/sections/newsletter-cta
```
### Create Section
```http
POST /_emdash/api/sections
Content-Type: application/json
{
"slug": "my-section",
"title": "My Section",
"description": "Optional description",
"keywords": ["keyword1", "keyword2"],
"content": [...]
}
```
### Update Section
```http
PUT /_emdash/api/sections/my-section
Content-Type: application/json
{
"title": "Updated Title",
"content": [...]
}
```
### Delete Section
```http
DELETE /_emdash/api/sections/my-section
```
## Next Steps
- [Working with Content](/guides/working-with-content/) - Learn about the rich text editor
- [Widget Areas](/guides/widgets/) - For synchronized dynamic content
- [Content Import](/migration/content-import/) - Import WordPress reusable blocks

View File

@@ -0,0 +1,326 @@
---
title: Site Settings
description: Configure global settings like site title, logo, and social links.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Site settings are global configuration values for your site: title, tagline, logo, social links, and display preferences. Administrators manage these through the admin interface, and you access them in your templates.
## Querying Settings
Use `getSiteSettings()` to fetch all site settings:
```astro title="src/layouts/Base.astro"
---
import { getSiteSettings } from "emdash";
const settings = await getSiteSettings();
---
<html lang="en">
<head>
<title>{settings.title}</title>
{settings.favicon && (
<link rel="icon" href={settings.favicon.url} />
)}
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
) : (
<span class="site-title">{settings.title}</span>
)}
{settings.tagline && <p class="tagline">{settings.tagline}</p>}
</header>
<slot />
</body>
</html>
```
## Available Settings
EmDash provides these core settings:
```ts
interface SiteSettings {
// Identity
title: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
// URLs
url?: string;
// Display
postsPerPage: number;
dateFormat: string;
timezone: string;
// Social
social?: {
twitter?: string;
github?: string;
facebook?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
};
}
interface MediaReference {
mediaId: string;
alt?: string;
url?: string; // Resolved URL (read-only)
}
```
## Fetching Individual Settings
Use `getSiteSetting()` to fetch a single setting by key:
```ts
import { getSiteSetting } from "emdash";
const title = await getSiteSetting("title");
// Returns: "My Site" or undefined
const logo = await getSiteSetting("logo");
// Returns: { mediaId: "...", url: "/_emdash/api/media/file/..." }
```
This is useful when you only need one or two values and want to avoid fetching everything.
## Using Settings in Components
### Site Header
```astro title="src/components/Header.astro"
---
import { getSiteSettings, getMenu } from "emdash";
const settings = await getSiteSettings();
const menu = await getMenu("primary");
---
<header class="header">
<a href="/" class="logo">
{settings.logo ? (
<img
src={settings.logo.url}
alt={settings.logo.alt || settings.title}
width="150"
height="50"
/>
) : (
<span class="site-name">{settings.title}</span>
)}
</a>
{menu && (
<nav>
{menu.items.map(item => (
<a href={item.url}>{item.label}</a>
))}
</nav>
)}
</header>
```
### Social Links
```astro title="src/components/SocialLinks.astro"
---
import { getSiteSetting } from "emdash";
const social = await getSiteSetting("social");
const platforms = [
{ key: "twitter", label: "Twitter", baseUrl: "https://twitter.com/" },
{ key: "github", label: "GitHub", baseUrl: "https://github.com/" },
{ key: "facebook", label: "Facebook", baseUrl: "https://facebook.com/" },
{ key: "instagram", label: "Instagram", baseUrl: "https://instagram.com/" },
{ key: "linkedin", label: "LinkedIn", baseUrl: "https://linkedin.com/in/" },
{ key: "youtube", label: "YouTube", baseUrl: "https://youtube.com/@" },
] as const;
---
{social && (
<div class="social-links">
{platforms.map(({ key, label, baseUrl }) => (
social[key] && (
<a
href={baseUrl + social[key]}
rel="noopener noreferrer"
target="_blank"
aria-label={label}
>
{label}
</a>
)
))}
</div>
)}
```
### SEO Meta Tags
```astro title="src/components/SEO.astro"
---
import { getSiteSettings } from "emdash";
interface Props {
title?: string;
description?: string;
image?: string;
}
const settings = await getSiteSettings();
const {
title,
description = settings.tagline,
image,
} = Astro.props;
const documentTitle = title
? `${title} | ${settings.title}`
: settings.title;
const ogTitle = title ?? settings.title;
---
<title>{documentTitle}</title>
{description && <meta name="description" content={description} />}
<!-- Open Graph -->
<meta property="og:title" content={ogTitle} />
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
{settings.url && <meta property="og:url" content={settings.url + Astro.url.pathname} />}
<!-- Twitter -->
{settings.social?.twitter && (
<meta name="twitter:site" content={settings.social.twitter} />
)}
<meta name="twitter:card" content={image ? "summary_large_image" : "summary"} />
```
## Date Formatting
Use the `dateFormat` and `timezone` settings for consistent date display:
```astro title="src/components/PostDate.astro"
---
import { getSiteSetting } from "emdash";
interface Props {
date: string;
}
const { date } = Astro.props;
const dateFormat = await getSiteSetting("dateFormat") || "MMMM d, yyyy";
const timezone = await getSiteSetting("timezone") || "UTC";
// Format using Intl.DateTimeFormat or a library like date-fns
const formatted = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
dateStyle: "long",
}).format(new Date(date));
---
<time datetime={date}>{formatted}</time>
```
<Aside>
The `dateFormat` setting uses a pattern string (e.g., "MMMM d, yyyy"). You may need a library like
`date-fns` to parse and apply these patterns.
</Aside>
## Admin API
Fetch settings programmatically:
```http
GET /_emdash/api/settings
```
Response:
```json
{
"title": "My EmDash Site",
"tagline": "A modern CMS",
"logo": {
"mediaId": "med_123",
"url": "/_emdash/api/media/file/abc123"
},
"postsPerPage": 10,
"dateFormat": "MMMM d, yyyy",
"timezone": "America/New_York",
"social": {
"twitter": "@handle",
"github": "username"
}
}
```
Update settings (partial updates supported):
```http
POST /_emdash/api/settings
Content-Type: application/json
{
"title": "New Site Title",
"tagline": "Updated tagline"
}
```
Only the provided fields are changed. Omitted fields retain their current values.
## Media References
The `logo` and `favicon` settings store media references. When you read them, EmDash resolves the `url` property automatically:
```ts
const logo = await getSiteSetting("logo");
// {
// mediaId: "med_123",
// alt: "Site logo",
// url: "/_emdash/api/media/file/abc123"
// }
```
When updating via the API, provide only the `mediaId`:
```json
{
"logo": {
"mediaId": "med_456",
"alt": "New logo"
}
}
```
## API Reference
### `getSiteSettings()`
Fetch all site settings with resolved media URLs.
**Returns:** `Promise<Partial<SiteSettings>>`
Returns a partial object. Unset values are `undefined`.
### `getSiteSetting(key)`
Fetch a single setting by key.
**Parameters:**
- `key` — The setting key (e.g., `"title"`, `"logo"`, `"social"`)
**Returns:** `Promise<SiteSettings[K] | undefined>`
Type-safe: the return type matches the key you request.

View File

@@ -0,0 +1,458 @@
---
title: Taxonomies
description: Organize content with categories, tags, and custom taxonomies.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Taxonomies are classification systems for organizing content. EmDash includes built-in categories and tags, and supports custom taxonomies for specialized classification needs.
## Built-in Taxonomies
EmDash provides two default taxonomies:
| Taxonomy | Type | Description |
| -------------- | ------------ | ----------------------------------------------------- |
| **Categories** | Hierarchical | Nested classification with parent-child relationships |
| **Tags** | Flat | Simple labels without hierarchy |
Both are available for the posts collection by default.
## Managing Terms
### Create a Term
<Tabs>
<TabItem label="Admin Dashboard">
1. Go to the taxonomy page (e.g., `/_emdash/admin/taxonomies/category`)
2. Enter the term name in the **Add New** form
3. Optionally set:
- **Slug** - URL identifier (auto-generated from name)
- **Parent** - For hierarchical taxonomies
- **Description** - Term description
4. Click **Add**
</TabItem>
<TabItem label="Content Editor">
5. Open a content entry in the editor
6. Find the taxonomy panel in the sidebar
7. For categories: check the boxes for applicable terms, or click **+ Add New**
8. For tags: type tag names separated by commas
9. Save the content
</TabItem>
<TabItem label="API">
```bash
POST /_emdash/api/taxonomies/category/terms
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"slug": "tutorials",
"label": "Tutorials",
"parentId": "term_abc",
"description": "How-to guides and tutorials"
}
```
</TabItem>
</Tabs>
### Edit a Term
1. Go to the taxonomy terms page
2. Click **Edit** next to the term
3. Update the name, slug, parent, or description
4. Click **Save**
### Delete a Term
1. Go to the taxonomy terms page
2. Click **Delete** next to the term
3. Confirm the deletion
<Aside type="caution">
Deleting a term removes it from all associated content. Content is not deleted, only the term
assignment.
</Aside>
## Querying Taxonomies
EmDash provides functions to query taxonomy terms and filter content by term.
### Get All Terms
Retrieve all terms for a taxonomy:
```ts
import { getTaxonomyTerms } from "emdash";
// Get all categories (returns tree structure)
const categories = await getTaxonomyTerms("category");
// Get all tags (returns flat list)
const tags = await getTaxonomyTerms("tag");
```
For hierarchical taxonomies, terms include a `children` array:
```ts
interface TaxonomyTerm {
id: string;
name: string; // Taxonomy name ("category")
slug: string; // Term slug ("news")
label: string; // Display label ("News")
parentId?: string;
description?: string;
children: TaxonomyTerm[];
count?: number; // Number of entries with this term
}
```
### Get a Single Term
```ts
import { getTerm } from "emdash";
const category = await getTerm("category", "news");
// Returns TaxonomyTerm or null
```
### Get Terms for an Entry
```ts
import { getEntryTerms } from "emdash";
// Get all categories for a post
const categories = await getEntryTerms("posts", "post-123", "category");
// Get all tags for a post
const tags = await getEntryTerms("posts", "post-123", "tag");
```
### Filter Content by Term
Use `getEmDashCollection` with the `where` filter:
```ts
import { getEmDashCollection } from "emdash";
// Posts in the "news" category
const { entries: newsPosts } = await getEmDashCollection("posts", {
status: "published",
where: { category: "news" },
});
// Posts with the "javascript" tag
const { entries: jsPosts } = await getEmDashCollection("posts", {
status: "published",
where: { tag: "javascript" },
});
```
Or use the convenience function:
```ts
import { getEntriesByTerm } from "emdash";
const newsPosts = await getEntriesByTerm("posts", "category", "news");
```
## Building Taxonomy Pages
### Category Archive
Create a page that lists posts in a category:
```astro title="src/pages/category/[slug].astro"
---
import { getTaxonomyTerms, getTerm, getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const categories = await getTaxonomyTerms("category");
// Flatten hierarchical tree for routing
function flatten(terms) {
return terms.flatMap((term) => [term, ...flatten(term.children)]);
}
return flatten(categories).map((cat) => ({
params: { slug: cat.slug },
props: { category: cat },
}));
}
const { category } = Astro.props;
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
where: { category: category.slug },
});
---
<Base title={category.label}>
<h1>{category.label}</h1>
{category.description && <p>{category.description}</p>}
<p>{category.count} posts</p>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
### Tag Archive
Create a page that lists posts with a tag:
```astro title="src/pages/tag/[slug].astro"
---
import { getTaxonomyTerms, getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const tags = await getTaxonomyTerms("tag");
return tags.map((tag) => ({
params: { slug: tag.slug },
props: { tag },
}));
}
const { tag } = Astro.props;
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
where: { tag: tag.slug },
});
---
<Base title={`Posts tagged "${tag.label}"`}>
<h1>#{tag.label}</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
### Category List Widget
Display a list of categories with post counts:
```astro title="src/components/CategoryList.astro"
---
import { getTaxonomyTerms } from "emdash";
const categories = await getTaxonomyTerms("category");
---
<nav class="category-list">
<h3>Categories</h3>
<ul>
{categories.map((cat) => (
<li>
<a href={`/category/${cat.slug}`}>
{cat.label} ({cat.count})
</a>
{cat.children.length > 0 && (
<ul>
{cat.children.map((child) => (
<li>
<a href={`/category/${child.slug}`}>
{child.label} ({child.count})
</a>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
```
### Tag Cloud
Display tags with size based on usage:
```astro title="src/components/TagCloud.astro"
---
import { getTaxonomyTerms } from "emdash";
const tags = await getTaxonomyTerms("tag");
// Calculate font sizes based on count
const counts = tags.map((t) => t.count ?? 0);
const maxCount = Math.max(...counts, 1);
const minSize = 0.8;
const maxSize = 2;
function getSize(count: number) {
const ratio = count / maxCount;
return minSize + ratio * (maxSize - minSize);
}
---
<div class="tag-cloud">
{tags.map((tag) => (
<a
href={`/tag/${tag.slug}`}
style={`font-size: ${getSize(tag.count ?? 0)}rem`}
>
{tag.label}
</a>
))}
</div>
```
## Displaying Terms on Content
Show categories and tags on a post:
```astro title="src/components/PostTerms.astro"
---
import { getEntryTerms } from "emdash";
interface Props {
collection: string;
entryId: string;
}
const { collection, entryId } = Astro.props;
const categories = await getEntryTerms(collection, entryId, "category");
const tags = await getEntryTerms(collection, entryId, "tag");
---
<div class="post-terms">
{categories.length > 0 && (
<div class="categories">
<span>Posted in:</span>
{categories.map((cat, i) => (
<>
{i > 0 && ", "}
<a href={`/category/${cat.slug}`}>{cat.label}</a>
</>
))}
</div>
)}
{tags.length > 0 && (
<div class="tags">
{tags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="tag">
#{tag.label}
</a>
))}
</div>
)}
</div>
```
## Custom Taxonomies
Create taxonomies beyond categories and tags for specialized needs.
### Create a Custom Taxonomy
Use the admin API to create a taxonomy:
```bash
POST /_emdash/api/taxonomies
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"name": "genre",
"label": "Genres",
"labelSingular": "Genre",
"hierarchical": true,
"collections": ["books", "movies"]
}
```
### Use Custom Taxonomies
Query and display custom taxonomies the same way as built-in ones:
```ts
import { getTaxonomyTerms, getEmDashCollection } from "emdash";
// Get all genres
const genres = await getTaxonomyTerms("genre");
// Get books in a genre
const { entries: sciFiBooks } = await getEmDashCollection("books", {
where: { genre: "science-fiction" },
});
```
### Assign to Collections
Taxonomies specify which collections they apply to:
```ts
{
"name": "difficulty",
"label": "Difficulty Levels",
"hierarchical": false,
"collections": ["recipes", "tutorials"]
}
```
## Taxonomy API Reference
### REST Endpoints
| Endpoint | Method | Description |
| --------------------------------------------- | ------ | ------------------------- |
| `/_emdash/api/taxonomies` | GET | List taxonomy definitions |
| `/_emdash/api/taxonomies` | POST | Create taxonomy |
| `/_emdash/api/taxonomies/:name/terms` | GET | List terms |
| `/_emdash/api/taxonomies/:name/terms` | POST | Create term |
| `/_emdash/api/taxonomies/:name/terms/:slug` | GET | Get term |
| `/_emdash/api/taxonomies/:name/terms/:slug` | PUT | Update term |
| `/_emdash/api/taxonomies/:name/terms/:slug` | DELETE | Delete term |
### Assign Terms to Content
```bash
POST /_emdash/api/content/posts/post-123/terms/category
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"termIds": ["term_news", "term_featured"]
}
```
## Next Steps
- [Create a Blog](/guides/create-a-blog/) - Use categories and tags in a blog
- [Querying Content](/guides/querying-content/) - Filter by taxonomy terms
- [Working with Content](/guides/working-with-content/) - Assign terms in the editor

View File

@@ -0,0 +1,363 @@
---
title: Widget Areas
description: Add dynamic content blocks to sidebars, footers, and other template regions.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Widget areas are named regions in your templates where administrators can place content blocks. Use them for sidebars, footer columns, promotional banners, or any section that editors should control without touching code.
## Querying Widget Areas
Use `getWidgetArea()` to fetch a widget area by name:
```astro title="src/layouts/Base.astro"
---
import { getWidgetArea } from "emdash";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar && sidebar.widgets.length > 0 && (
<aside class="sidebar">
{sidebar.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
<!-- Render widget content -->
</div>
))}
</aside>
)}
```
The function returns `null` if the widget area does not exist.
## Widget Area Structure
A widget area contains metadata and an array of widgets:
```ts
interface WidgetArea {
id: string;
name: string; // Unique identifier ("sidebar", "footer-1")
label: string; // Display name ("Main Sidebar")
description?: string;
widgets: Widget[];
}
interface Widget {
id: string;
type: "content" | "menu" | "component";
title?: string;
// Type-specific fields
content?: PortableTextBlock[]; // For content widgets
menuName?: string; // For menu widgets
componentId?: string; // For component widgets
componentProps?: Record<string, unknown>;
}
```
## Widget Types
EmDash supports three widget types:
### Content Widgets
Rich text content stored as Portable Text. Render using the `PortableText` component:
```astro
---
import { PortableText } from "emdash/ui";
---
{widget.type === "content" && widget.content && (
<div class="widget-content">
<PortableText value={widget.content} />
</div>
)}
```
### Menu Widgets
Display a navigation menu within a widget area:
```astro
---
import { getMenu } from "emdash";
const menu = widget.menuName ? await getMenu(widget.menuName) : null;
---
{widget.type === "menu" && menu && (
<nav class="widget-nav">
<ul>
{menu.items.map(item => (
<li><a href={item.url}>{item.label}</a></li>
))}
</ul>
</nav>
)}
```
### Component Widgets
Render a registered component with configurable props. EmDash includes these core components:
| Component ID | Description | Props |
| ------------------- | ----------------------- | ------------------------------------- |
| `core:recent-posts` | List of recent posts | `count`, `showThumbnails`, `showDate` |
| `core:categories` | Category list | `showCount`, `hierarchical` |
| `core:tags` | Tag cloud | `showCount`, `limit` |
| `core:search` | Search form | `placeholder` |
| `core:archives` | Monthly/yearly archives | `type`, `limit` |
## Rendering Widgets
Create a reusable widget renderer component:
```astro title="src/components/WidgetRenderer.astro"
---
import { PortableText } from "emdash/ui";
import { getMenu } from "emdash";
import type { Widget } from "emdash";
// Import your widget components
import RecentPosts from "./widgets/RecentPosts.astro";
import Categories from "./widgets/Categories.astro";
import TagCloud from "./widgets/TagCloud.astro";
import SearchForm from "./widgets/SearchForm.astro";
import Archives from "./widgets/Archives.astro";
interface Props {
widget: Widget;
}
const { widget } = Astro.props;
const componentMap: Record<string, any> = {
"core:recent-posts": RecentPosts,
"core:categories": Categories,
"core:tags": TagCloud,
"core:search": SearchForm,
"core:archives": Archives,
};
const menu = widget.type === "menu" && widget.menuName
? await getMenu(widget.menuName)
: null;
---
<div class="widget">
{widget.title && <h3 class="widget-title">{widget.title}</h3>}
{widget.type === "content" && widget.content && (
<div class="widget-content">
<PortableText value={widget.content} />
</div>
)}
{widget.type === "menu" && menu && (
<nav class="widget-menu">
<ul>
{menu.items.map(item => (
<li><a href={item.url}>{item.label}</a></li>
))}
</ul>
</nav>
)}
{widget.type === "component" && widget.componentId && componentMap[widget.componentId] && (
<Fragment>
{(() => {
const Component = componentMap[widget.componentId!];
return <Component {...widget.componentProps} />;
})()}
</Fragment>
)}
</div>
```
## Example Widget Components
### Recent Posts Widget
```astro title="src/components/widgets/RecentPosts.astro"
---
import { getEmDashCollection } from "emdash";
interface Props {
count?: number;
showThumbnails?: boolean;
showDate?: boolean;
}
const { count = 5, showThumbnails = false, showDate = true } = Astro.props;
const { entries: posts } = await getEmDashCollection("posts", {
limit: count,
orderBy: { publishedAt: "desc" },
});
---
<ul class="recent-posts">
{posts.map(post => (
<li>
{showThumbnails && post.data.featured_image && (
<img src={post.data.featured_image} alt="" class="thumbnail" />
)}
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
{showDate && post.data.publishedAt && (
<time datetime={post.data.publishedAt.toISOString()}>
{post.data.publishedAt.toLocaleDateString()}
</time>
)}
</li>
))}
</ul>
```
### Search Widget
```astro title="src/components/widgets/SearchForm.astro"
---
interface Props {
placeholder?: string;
}
const { placeholder = "Search..." } = Astro.props;
---
<form action="/search" method="get" class="search-form">
<input
type="search"
name="q"
placeholder={placeholder}
aria-label="Search"
/>
<button type="submit">Search</button>
</form>
```
## Using Widget Areas in Layouts
The following example shows a blog layout with a sidebar widget area:
```astro title="src/layouts/BlogPost.astro"
---
import { getWidgetArea } from "emdash";
import WidgetRenderer from "../components/WidgetRenderer.astro";
const sidebar = await getWidgetArea("sidebar");
---
<div class="layout">
<main class="content">
<slot />
</main>
{sidebar && sidebar.widgets.length > 0 && (
<aside class="sidebar">
{sidebar.widgets.map(widget => (
<WidgetRenderer widget={widget} />
))}
</aside>
)}
</div>
<style>
.layout {
display: grid;
grid-template-columns: 1fr 300px;
gap: 2rem;
}
@media (max-width: 768px) {
.layout {
grid-template-columns: 1fr;
}
}
</style>
```
## Listing All Widget Areas
Use `getWidgetAreas()` to retrieve all widget areas with their widgets:
```ts
import { getWidgetAreas } from "emdash";
const areas = await getWidgetAreas();
// Returns all areas with widgets populated
```
## Creating Widget Areas
Create widget areas through the admin interface at `/_emdash/admin/widgets`, or use the admin API:
```http
POST /_emdash/api/widget-areas
Content-Type: application/json
{
"name": "footer-1",
"label": "Footer Column 1",
"description": "First column in the footer"
}
```
Add a content widget:
```http
POST /_emdash/api/widget-areas/footer-1/widgets
Content-Type: application/json
{
"type": "content",
"title": "About Us",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to our site." }]
}
]
}
```
Add a component widget:
```http
POST /_emdash/api/widget-areas/sidebar/widgets
Content-Type: application/json
{
"type": "component",
"title": "Recent Posts",
"componentId": "core:recent-posts",
"componentProps": { "count": 5, "showDate": true }
}
```
## API Reference
### `getWidgetArea(name)`
Fetch a widget area by name with all widgets.
**Parameters:**
- `name` — The widget area's unique identifier (string)
**Returns:** `Promise<WidgetArea | null>`
### `getWidgetAreas()`
List all widget areas with their widgets.
**Returns:** `Promise<WidgetArea[]>`
### `getWidgetComponents()`
List available widget component definitions for the admin UI.
**Returns:** `WidgetComponentDef[]`

View File

@@ -0,0 +1,283 @@
---
title: Working with Content
description: Create, edit, and manage content in the EmDash admin dashboard.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
This guide covers how to create, edit, and manage content using the EmDash admin dashboard.
## Accessing the Admin
Open your browser to `/_emdash/admin` on your site. Log in with the credentials you created during setup.
The dashboard displays:
- **Sidebar** - Navigation to collections, media, and settings
- **Content list** - Entries in the selected collection
- **Quick actions** - Create new content, bulk operations
## Creating Content
1. Click a collection name in the sidebar (e.g., **Posts**)
2. Click **New Post** (or the equivalent for your collection)
3. Fill in the required fields:
- **Title** - The content's display name
- **Slug** - URL identifier (auto-generated from title, editable)
4. Add content using the rich text editor
5. Set metadata in the sidebar:
- **Status** - Draft, Published, or Archived
- **Publication date** - When to publish
- **Categories and tags** - Taxonomy assignments
6. Click **Save**
<Aside>
Drafts are only visible in the admin. Change status to **Published** to make content visible on
your site.
</Aside>
## Content Statuses
Every entry has one of three statuses:
| Status | Visibility | Use case |
| ------------- | ---------- | ---------------- |
| **Draft** | Admin only | Work in progress |
| **Published** | Public | Live content |
| **Archived** | Admin only | Retired content |
Change status using the dropdown in the editor sidebar.
## The Rich Text Editor
EmDash's editor supports:
- **Headings** - H2 through H6
- **Formatting** - Bold, italic, underline, strikethrough
- **Lists** - Ordered and unordered
- **Links** - Internal and external
- **Images** - Insert from media library
- **Code blocks** - With syntax highlighting
- **Embeds** - YouTube, Vimeo, Twitter
- **Sections** - Reusable content blocks via `/section` command
### Slash Commands
Type `/` to access quick insert commands:
| Command | Action |
| ---------------------------- | ----------------------------------- |
| `/section` | Insert a reusable section |
| `/image` | Insert an image from media library |
| `/code` | Insert a code block |
### Keyboard Shortcuts
| Action | Shortcut |
| ------ | ---------------------- |
| Bold | `Ctrl/Cmd + B` |
| Italic | `Ctrl/Cmd + I` |
| Link | `Ctrl/Cmd + K` |
| Undo | `Ctrl/Cmd + Z` |
| Redo | `Ctrl/Cmd + Shift + Z` |
| Save | `Ctrl/Cmd + S` |
### Inserting Images
1. Click the image button in the toolbar
2. Select an existing image from the media library, or upload a new one
3. Add alt text (required for accessibility)
4. Adjust alignment and size options
5. Click **Insert**
## Editing Content
1. Navigate to the collection containing the content
2. Click on the entry you want to edit
3. Make your changes
4. Click **Save**
Changes to published content appear immediately on your site. No rebuild required.
### Revision History
EmDash tracks changes to content. Access revision history from the editor sidebar:
1. Click **Revisions** in the editor sidebar
2. View the list of previous versions with timestamps
3. Click a revision to preview it
4. Click **Restore** to revert to that version
<Aside type="caution">
Restoring a revision creates a new revision with the restored content. The original revision
history is preserved.
</Aside>
## Bulk Operations
Perform actions on multiple entries at once:
1. Use the checkboxes to select entries in the content list
2. Click the **Bulk Actions** dropdown
3. Select an action:
- **Publish** - Set all selected to published
- **Archive** - Set all selected to archived
- **Delete** - Permanently remove selected
4. Confirm the action
## Searching and Filtering
### Search
Use the search box to find content by title or content. Search is case-insensitive and matches partial words.
### Filters
Filter the content list by:
- **Status** - Draft, Published, Archived
- **Date range** - Created or modified dates
- **Author** - Who created the content
- **Taxonomy** - Category or tag assignments
Click **Clear Filters** to reset.
## Scheduling Content
Schedule content to publish at a future date:
1. Create or edit content
2. Set status to **Draft**
3. Set the **Publication date** to a future date and time
4. Click **Save**
When the publication date arrives, the content automatically becomes published.
<Aside type="tip">
Scheduled publishing requires your site to be server-rendered or have a scheduled task that checks
for pending publications.
</Aside>
## Deleting Content
Delete content from the edit screen or content list:
### From the Editor
1. Open the content you want to delete
2. Click **Delete** in the toolbar
3. Confirm the deletion
### From the List
1. Select entries using checkboxes
2. Click **Bulk Actions** > **Delete**
3. Confirm the deletion
<Aside type="caution">
Deleted content is permanently removed and cannot be recovered. Consider archiving instead if you
might need the content later.
</Aside>
## Content API
For programmatic access, use the EmDash admin API:
### Create Content
```bash
POST /_emdash/api/content/posts
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"title": "My New Post",
"slug": "my-new-post",
"content": "<p>Post content here</p>",
"status": "draft"
}
```
### Update Content
```bash
PUT /_emdash/api/content/posts/my-new-post
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"title": "Updated Title",
"status": "published"
}
```
### Delete Content
```bash
DELETE /_emdash/api/content/posts/my-new-post
Authorization: Bearer YOUR_API_TOKEN
```
## Translating Content
When [i18n is enabled](/guides/internationalization/), you can create translations of any content entry.
### Creating a translation
1. Open the content entry you want to translate
2. In the editor sidebar, find the **Translations** panel
3. Click **Translate** next to the target locale
4. Edit the pre-filled content — adjust the title, slug, and body for the new language
5. Click **Save**
The new translation is linked to the original entry and starts as a draft. Publish it independently when the translation is ready.
### Switching between translations
The Translations panel shows all configured locales. Click **Edit** next to any existing translation to navigate to it directly. The current locale is marked with a checkmark.
### Locale filter
In the content list, use the locale dropdown in the toolbar to filter entries by language. Each entry shows its locale in a dedicated column.
<Aside type="tip">
Each translation has its own slug, status, and revision history. Publish, schedule, and manage translations independently.
</Aside>
See the [Internationalization guide](/guides/internationalization/) for full details on configuration, querying, and the language switcher.
## Next Steps
- [Querying Content](/guides/querying-content/) - Retrieve content in your templates
- [Media Library](/guides/media-library/) - Upload and manage files
- [Taxonomies](/guides/taxonomies/) - Organize content with categories and tags
- [Internationalization](/guides/internationalization/) - Multilingual content and translations

View File

@@ -0,0 +1,253 @@
---
title: x402 Payments
description: Monetize content with the x402 payment protocol — charge bots, not humans.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
The `@emdash-cms/x402` package adds [x402 payment protocol](https://www.x402.org/) support to any Astro site on Cloudflare. It works standalone — no dependency on EmDash core — but pairs well with EmDash's CMS fields for per-page pricing.
x402 is an HTTP-native payment protocol. When a client requests a paid resource without payment, the server responds with `402 Payment Required` and machine-readable payment instructions. Agents and browsers that understand x402 can complete payment automatically and retry the request.
## When to Use This
The most common use case is **bot-only mode**: charge AI agents and scrapers for content access while letting human visitors read for free. This uses Cloudflare Bot Management to distinguish bots from humans.
You can also enforce payment for all visitors, or check for payment headers without enforcing (conditional rendering).
## Installation
<Tabs>
<TabItem label="pnpm">
```bash
pnpm add @emdash-cms/x402
```
</TabItem>
<TabItem label="npm">
```bash
npm install @emdash-cms/x402
```
</TabItem>
<TabItem label="yarn">
```bash
yarn add @emdash-cms/x402
```
</TabItem>
</Tabs>
## Setup
Add the integration to your Astro config:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import { x402 } from "@emdash-cms/x402";
export default defineConfig({
integrations: [
x402({
payTo: "0xYourWalletAddress",
network: "eip155:8453", // Base mainnet
defaultPrice: "$0.01",
botOnly: true,
botScoreThreshold: 30,
}),
],
});
```
Add the type reference so TypeScript knows about `Astro.locals.x402`:
```ts title="src/env.d.ts"
/// <reference types="@emdash-cms/x402/locals" />
```
## Basic Usage
The integration puts an enforcer on `Astro.locals.x402`. Call `enforce()` in your page frontmatter to gate content behind payment:
```astro title="src/pages/posts/[...slug].astro"
---
const { x402 } = Astro.locals;
const result = await x402.enforce(Astro.request, {
price: "$0.05",
description: "Premium article",
});
// If the request has no valid payment, enforce() returns a 402 Response.
// Return it directly to send payment instructions to the client.
if (result instanceof Response) return result;
// Payment verified (or skipped in botOnly mode). Apply response headers
// so the client gets settlement proof.
x402.applyHeaders(result, Astro.response);
---
<article>
<h1>Premium content</h1>
</article>
```
The `enforce()` method returns either:
- A **`Response`** (402) — the client needs to pay. Return it directly.
- An **`EnforceResult`** — the request should proceed. The content was paid for, or enforcement was skipped (human in botOnly mode).
## Bot-Only Mode
When `botOnly` is `true`, the integration reads `request.cf.botManagement.score` to classify requests:
- **Score below threshold** (default 30) -> treated as bot, payment enforced
- **Score at or above threshold** -> treated as human, enforcement skipped
- **No bot management data** (local dev, non-CF deployment) -> treated as human
The `EnforceResult` includes a `skipped` flag so you can distinguish "didn't need to pay" from "paid":
```astro
---
const result = await x402.enforce(Astro.request, { price: "$0.01" });
if (result instanceof Response) return result;
x402.applyHeaders(result, Astro.response);
// result.paid — true if payment was verified
// result.skipped — true if enforcement was skipped (human in botOnly mode)
// result.payer — wallet address of payer (if paid)
---
```
<Aside>
Bot-only mode requires a Cloudflare deployment with Bot Management enabled. In local development,
all requests are treated as human and enforcement is skipped.
</Aside>
## Per-Page Pricing with EmDash
When using EmDash, you can add a `number` field to your collection for per-page pricing. No special schema or admin UI is needed — just a regular CMS field:
```astro title="src/pages/posts/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry } = await getEmDashEntry("posts", slug);
if (!entry) return Astro.redirect("/404");
const { x402 } = Astro.locals;
// Use the price from the CMS, falling back to a default
const result = await x402.enforce(Astro.request, {
price: entry.data.price || "$0.01",
description: entry.data.title,
});
if (result instanceof Response) return result;
x402.applyHeaders(result, Astro.response);
---
<article>
<h1>{entry.data.title}</h1>
</article>
```
## Checking for Payment Without Enforcing
Use `hasPayment()` to check if a request includes payment headers without verifying or enforcing. This is useful for conditional rendering — showing different content to paying vs non-paying visitors:
```astro
---
const { x402 } = Astro.locals;
const hasPaid = x402.hasPayment(Astro.request);
---
{hasPaid ? (
<p>Full premium content here.</p>
) : (
<p>Subscribe for the full article.</p>
)}
```
<Aside type="caution">
`hasPayment()` only checks for the presence of a payment header. It does not verify the payment
is valid. Use `enforce()` when you need verified payment.
</Aside>
## Configuration Reference
| Option | Type | Default | Description |
| ------------------- | --------- | -------------------------------- | ------------------------------------------------ |
| `payTo` | `string` | required | Destination wallet address |
| `network` | `string` | required | CAIP-2 network identifier (e.g., `eip155:8453`) |
| `defaultPrice` | `Price` | — | Default price, overridable per-page |
| `facilitatorUrl` | `string` | `https://x402.org/facilitator` | Payment facilitator URL |
| `scheme` | `string` | `"exact"` | Payment scheme |
| `maxTimeoutSeconds` | `number` | `60` | Maximum timeout for payment signatures |
| `evm` | `boolean` | `true` | Enable EVM chain support |
| `svm` | `boolean` | `false` | Enable Solana chain support (requires `@x402/svm`) |
| `botOnly` | `boolean` | `false` | Only enforce payment for bots |
| `botScoreThreshold` | `number` | `30` | Bot score threshold (1-99, lower = more likely bot) |
### Price Format
Prices can be specified in several formats:
- **Dollar string** — `"$0.10"` (the `$` prefix is stripped, value passed as-is)
- **Numeric string** — `"0.10"`
- **Number** — `0.10`
- **Object** — `{ amount: "100000", asset: "0x...", extra: {} }` for explicit asset/amount
### Network Identifiers
Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format:
| Network | Identifier |
| ------------- | ---------------- |
| Base mainnet | `eip155:8453` |
| Base Sepolia | `eip155:84532` |
| Ethereum | `eip155:1` |
| Solana | `solana:mainnet` |
## Enforce Options
Override config defaults for a specific page:
```ts
await x402.enforce(Astro.request, {
price: "$0.25", // Override price
payTo: "0xDifferentWallet", // Override wallet
network: "eip155:1", // Override network
description: "Article: How x402 Works", // Resource description
mimeType: "text/html", // MIME type hint
});
```
## Solana Support
Solana is opt-in. Install `@x402/svm` and enable it in config:
```bash
pnpm add @x402/svm
```
```js title="astro.config.mjs"
x402({
payTo: "YourSolanaAddress",
network: "solana:mainnet",
svm: true,
evm: false, // Disable EVM if only using Solana
});
```
## How It Works
1. The `x402()` integration registers middleware that creates an enforcer and places it on `Astro.locals.x402`
2. Configuration is passed to the middleware via a Vite virtual module (`virtual:x402/config`)
3. When `enforce()` is called, it checks for a `payment-signature` header on the request
4. If no payment header is present, a `402 Payment Required` response is returned with payment instructions in the `PAYMENT-REQUIRED` header
5. If a payment header is present, it's verified through the facilitator service and settled
6. After settlement, `PAYMENT-RESPONSE` headers are set on the response via `applyHeaders()`
The resource server is initialized lazily on first request and cached for the worker lifetime.

View File

@@ -0,0 +1,57 @@
---
title: EmDash
description: The Astro-native CMS. A modern successor to WordPress with type-safe content, plugin extensibility, and portable deployment.
template: splash
hero:
tagline: A modern, Astro-native CMS. Type-safe content, plugin extensibility, and portable deployment.
actions:
- text: Get Started
link: /getting-started/
icon: right-arrow
- text: View on GitHub
link: https://github.com/emdash-cms/emdash
icon: external
variant: minimal
---
import { Card, CardGrid } from "@astrojs/starlight/components";
## Why EmDash?
<CardGrid stagger>
<Card title="Astro-Native" icon="astro">
Built on Astro 6's Live Content Collections. No rebuilds needed—content updates instantly at
runtime.
</Card>
<Card title="Type-Safe" icon="setting">
Schema defined in the database, TypeScript types generated automatically. Full type safety from
database to template.
</Card>
<Card title="Plugin Ecosystem" icon="puzzle">
WordPress-inspired plugin system with hooks, storage, and admin UI extensions. Agent-portable
plugin migration from WordPress.
</Card>
</CardGrid>
## Quick Start
```bash
# Create a new EmDash site
npm create astro@latest -- --template @emdash-cms/template-blog
# Start the dev server
npm run dev
# Visit the admin at http://localhost:4321/_emdash/admin
```
## Features
- **Visual Schema Builder** — Create collections and fields from the admin panel
- **Rich Text Editor** — TipTap-powered editing with Portable Text storage
- **Media Library** — Drag-and-drop uploads with signed URL support
- **Navigation Menus** — Admin-editable menus with nested items
- **Taxonomies** — Categories, tags, and custom classification systems
- **Widget Areas** — Configurable content regions for sidebars and footers
- **WordPress Import** — Migrate content from WXR exports or REST API
- **Preview System** — Token-based preview for draft content

View File

@@ -0,0 +1,101 @@
---
title: Introduction to EmDash
description: Learn what EmDash is, how it works, and whether it's right for your project.
---
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
EmDash is an **Astro-native content management system**. It brings familiar CMS patterns—collections, taxonomies, menus, widgets, and a polished admin UI—directly into your Astro site with full TypeScript support and portable deployment.
## What EmDash Is
EmDash is a CMS built specifically for [Astro](https://astro.build). It uses Astro 6's Live Content Collections to serve content at runtime without rebuilds. Content is stored in SQLite-compatible databases (D1, libSQL, local SQLite) and media in S3-compatible storage (R2, local filesystem).
**Key characteristics:**
- **Database-first schema** — Collections and fields are defined in the database, not code. Create and modify content types from the admin UI.
- **Live Collections** — Content changes are immediately available. No static rebuilds needed.
- **Plugin system** — WordPress-inspired hooks, storage, settings, and admin UI extensions.
- **Cloud-portable** — Runs on Cloudflare (Workers + D1 + R2), Node.js, local SQLite, and any S3-compatible storage.
## What EmDash Is Not
- **Not a headless CMS** — EmDash is tightly integrated with Astro. It's not a separate service you call via API.
- **Not WordPress-compatible** — No PHP, no WordPress plugins running directly. But WordPress content and concepts migrate cleanly.
- **Not a page builder** — EmDash focuses on structured content. For visual page building, use Astro components.
## Who EmDash Is For
<CardGrid>
<Card title="Agency developers" icon="laptop">
Spin up client sites quickly with reusable plugins and themes. No PHP security updates, no
plugin conflicts.
</Card>
<Card title="Solo developers" icon="seti:todo">
Full-stack framework with CMS built in. No separate headless CMS to manage.
</Card>
<Card title="Content editors" icon="pencil">
Intuitive admin panel. Create and edit content without touching code.
</Card>
<Card title="WordPress users" icon="right-arrow">
Migration path for content and plugins. Modern tooling, familiar concepts.
</Card>
</CardGrid>
## Architecture at a Glance
```
┌─────────────────────────────────────────────────────────────┐
│ Your Astro Site │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ EmDash Integration │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
│ │ │ Engine │ │ Panel │ │ │ │ │
│ │ └─────────────┘ └─────────────┘ └──────────────┘ │ │
│ │ │ │
│ │ ┌───────────────────────────────────────────────────┐│ │
│ │ │ Data Layer ││ │
│ │ │ SQLite/D1 ←→ Kysely ←→ R2/S3/Local ││ │
│ │ └───────────────────────────────────────────────────┘│ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Astro Framework │ │
│ │ Live Collections • Sessions • Middleware │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
<Aside type="tip">
EmDash is Astro-native and cloud-portable. It runs on Cloudflare (D1 + R2 + Workers), Node.js,
local SQLite, and any S3-compatible storage.
</Aside>
## Core Concepts
Before diving in, familiarize yourself with these key concepts:
- **Collections** — Content types defined in the database (posts, pages, products, etc.)
- **Fields** — The properties of a collection (title, content, price, etc.)
- **Taxonomies** — Classification systems (categories, tags, custom taxonomies)
- **Menus** — Admin-editable navigation structures
- **Widget Areas** — Configurable content regions for sidebars and footers
- **Plugins** — Extensions that add functionality via hooks, storage, and UI
## Next Steps
<CardGrid>
<Card title="Get Started" icon="rocket">
[Create your first EmDash site](/getting-started/) in under 5 minutes.
</Card>
<Card title="Explore Concepts" icon="open-book">
Learn about [architecture](/concepts/architecture/) and the [content
model](/concepts/content-model/).
</Card>
<Card title="Migrate from WordPress" icon="right-arrow">
[Import your WordPress content](/migration/from-wordpress/) and understand the concept mapping.
</Card>
</CardGrid>

View File

@@ -0,0 +1,427 @@
---
title: Content Import
description: Import content from WordPress and other sources into EmDash.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash's import system uses a pluggable source architecture. Each source knows how to probe, analyze, and fetch content from a specific platform.
## Import Sources
| Source ID | Platform | Probe | OAuth | Full Import |
| ---------------- | --------------------- | ----- | ----- | ----------- |
| `wxr` | WordPress export file | No | No | Yes |
| `wordpress-com` | WordPress.com | Yes | Yes | Yes |
| `wordpress-rest` | Self-hosted WordPress | Yes | No | Probe only |
### WXR File Upload
The most complete import method. Upload a WordPress eXtended RSS (WXR) export file directly to the admin dashboard.
**Capabilities:**
- All post types (including custom)
- All meta fields
- Drafts and private posts
- Full taxonomy hierarchy
- Media attachment metadata
**How to get a WXR file:**
<Steps>
1. In WordPress admin, go to **Tools → Export**
2. Select **All content** or specific post types
3. Click **Download Export File**
4. Upload the `.xml` file to EmDash
</Steps>
### WordPress.com OAuth
For sites hosted on WordPress.com, connect via OAuth to import without manual file exports.
<Steps>
1. Enter your WordPress.com site URL
2. Click **Connect with WordPress.com**
3. Authorize EmDash in the WordPress.com popup
4. Select content to import
</Steps>
<Aside type="caution">
WordPress.com OAuth requires environment variables `WPCOM_CLIENT_ID` and `WPCOM_CLIENT_SECRET`.
Register an app at [developer.wordpress.com](https://developer.wordpress.com/apps/).
</Aside>
**What's included:**
- Published and draft content
- Private posts (with authorization)
- Media files via API
- Custom fields exposed to REST API
### WordPress REST API Probe
When you enter a URL, EmDash probes the site to detect WordPress and show available content:
```
Detected: WordPress 6.4
├── Posts: 127 (published)
├── Pages: 12 (published)
└── Media: 89 files
Note: Drafts and private content require authentication
or a full WXR export.
```
The REST probe is informational. For complete imports, it suggests uploading a WXR file or connecting via OAuth (for WordPress.com).
## Import Flow
All sources follow the same flow:
```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Connect │────▶│ Analyze │────▶│ Prepare │────▶│ Execute │
│ (probe/ │ │ (schema │ │ (create │ │ (import │
│ upload) │ │ check) │ │ schema) │ │ content) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
```
### Step 1: Connect
Enter a URL to probe or upload a file directly.
**URL probing** runs all registered sources in parallel. The highest-confidence match determines the suggested next action:
- **WordPress.com site** → Offer OAuth connection
- **Self-hosted WordPress** → Show export instructions
- **Unknown** → Suggest file upload
### Step 2: Analyze
The source parses content and checks schema compatibility:
```
Post Types:
├── post (127) → posts [New collection]
├── page (12) → pages [Existing, compatible]
├── product (45) → products [Add 3 fields]
└── revision (234) → [Skip - internal type]
Required Schema Changes:
├── Create collection: posts
├── Add fields to pages: featured_image
└── Create collection: products
```
Each post type shows its status:
| Status | Meaning |
| -------------- | ---------------------------------------- |
| Ready | Collection exists with compatible fields |
| New collection | Will be created automatically |
| Add fields | Collection exists, missing fields added |
| Incompatible | Field type conflicts (manual fix needed) |
### Step 3: Prepare Schema
Click **Create Schema & Import** to:
1. Create new collections via SchemaRegistry
2. Add missing fields with correct column types
3. Set up content tables with indexes
### Step 4: Execute Import
Content imports sequentially:
- Gutenberg/HTML converted to Portable Text
- WordPress status mapped to EmDash status
- WordPress authors mapped to ownership (`authorId`) and presentation bylines
- Taxonomies created and linked
- Reusable blocks (`wp_block`) imported as [Sections](/guides/sections/)
- Progress shown in real-time
Author import behavior:
- If an author mapping points to an EmDash user, ownership is set to that user and a linked byline is created/reused for the same user.
- If there is no user mapping, a guest byline is created/reused from the WordPress author identity.
- Imported entries get ordered byline credits, with the first credit set as `primaryBylineId`.
### Step 5: Media Import (Optional)
After content, optionally import media:
<Steps>
1. **Analysis** — Shows attachment counts by type
```
Media found:
├── Images: 75 files
├── Video: 10 files
└── Other: 4 files
```
2. **Download** — Streams from WordPress URLs with progress
```
Importing media...
├── 45 of 89 (50%)
├── Current: vacation-photo.jpg
└── Status: Uploading
```
3. **Rewrite URLs** — Content automatically updated with new URLs
</Steps>
Media import uses content hashing (xxHash64) for deduplication. The same image used in multiple posts is stored once.
## Source Interface
Import sources implement a standard interface:
```typescript
interface ImportSource {
/** Unique identifier */
id: string;
/** Display name */
name: string;
/** Probe a URL (optional) */
probe?(url: string): Promise<SourceProbeResult | null>;
/** Analyze content from this source */
analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
/** Stream content items */
fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
}
```
### Input Types
Sources accept different input types:
```typescript
// File upload (WXR)
{ type: "file", file: File }
// URL with optional token (REST API)
{ type: "url", url: string, token?: string }
// OAuth connection (WordPress.com)
{ type: "oauth", url: string, accessToken: string }
```
### Normalized Output
All sources produce the same normalized format:
```typescript
interface NormalizedItem {
sourceId: string | number;
postType: string;
status: "publish" | "draft" | "pending" | "private" | "future";
slug: string;
title: string;
content: PortableTextBlock[];
excerpt?: string;
date: Date;
author?: string;
authors?: string[];
categories?: string[];
tags?: string[];
meta?: Record<string, unknown>;
featuredImage?: string;
}
```
## API Endpoints
The import system exposes these endpoints:
### Probe URL
```http
POST /_emdash/api/import/probe
Content-Type: application/json
{ "url": "https://example.com" }
```
Returns detected platform and suggested action.
### Analyze WXR
```http
POST /_emdash/api/import/wordpress/analyze
Content-Type: multipart/form-data
file: [WordPress export .xml]
```
Returns post type analysis with schema compatibility.
### Prepare Schema
```http
POST /_emdash/api/import/wordpress/prepare
Content-Type: application/json
{
"postTypes": [
{ "name": "post", "collection": "posts", "enabled": true }
]
}
```
Creates collections and fields.
### Execute Import
```http
POST /_emdash/api/import/wordpress/execute
Content-Type: multipart/form-data
file: [WordPress export .xml]
config: { "postTypeMappings": { "post": { "collection": "posts" } } }
```
Imports content to specified collections.
### Import Media
```http
POST /_emdash/api/import/wordpress/media
Content-Type: application/json
{
"attachments": [{ "id": 123, "url": "https://..." }],
"stream": true
}
```
Streams NDJSON progress updates during download/upload.
### Rewrite URLs
```http
POST /_emdash/api/import/wordpress/rewrite-urls
Content-Type: application/json
{
"urlMap": { "https://old.com/image.jpg": "/_emdash/media/abc123" }
}
```
Updates Portable Text content with new media URLs.
## Error Handling
### Recoverable Errors
- **Network timeout** — Retried with backoff
- **Single item parse failure** — Logged, skipped, import continues
- **Media download failure** — Marked for manual handling
### Fatal Errors
- **Invalid file format** — Import stops with error message
- **Database connection lost** — Import pauses, allows resume
- **Storage quota exceeded** — Import stops, shows usage
### Error Report
After import:
```
Import Complete
✓ 125 posts imported
✓ 12 pages imported
✓ 85 media references recorded
⚠ 2 items had warnings:
- Post "Special Characters ñ" - title encoding fixed
- Page "About" - duplicate slug renamed to "about-1"
✗ 1 item failed:
- Post ID 456 - content parsing error (saved as draft)
```
Failed items are saved as drafts with original content in `_importError` for review.
## Building Custom Sources
Create a source for other platforms:
```typescript title="src/import/custom-source.ts"
import type { ImportSource } from "emdash/import";
export const mySource: ImportSource = {
id: "my-platform",
name: "My Platform",
description: "Import from My Platform",
icon: "globe",
canProbe: true,
async probe(url) {
// Check if URL matches your platform
const response = await fetch(`${url}/api/info`);
if (!response.ok) return null;
return {
sourceId: "my-platform",
confidence: "definite",
detected: { platform: "my-platform" },
// ...
};
},
async analyze(input, context) {
// Parse and analyze content
// Return ImportAnalysis
},
async *fetchContent(input, options) {
// Yield NormalizedItem for each content piece
for (const item of items) {
yield {
sourceId: item.id,
postType: "post",
title: item.title,
content: convertToPortableText(item.body),
// ...
};
}
},
};
```
Register the source in your EmDash configuration:
```typescript title="astro.config.mjs"
import { mySource } from "./src/import/custom-source";
export default defineConfig({
integrations: [
emdash({
import: {
sources: [mySource],
},
}),
],
});
```
## Next Steps
- **[WordPress Migration](/migration/from-wordpress/)** — Complete WordPress migration guide
- **[Plugin Porting](/migration/plugin-porting/)** — Port WordPress plugins to EmDash

View File

@@ -0,0 +1,256 @@
---
title: Migrate from WordPress
description: Import your WordPress content into EmDash with a step-by-step guide.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash provides a complete migration path from WordPress. Import your posts, pages, media, and taxonomies through the admin dashboard—no CLI required.
## Before You Begin
<CardGrid>
<Card title="Export your content" icon="document">
In WordPress, go to **Tools → Export** and download a complete export file (.xml).
</Card>
<Card title="Back up your site" icon="warning">
Keep your WordPress site running until you verify the migration succeeded.
</Card>
</CardGrid>
## Import Methods
EmDash supports three methods for importing WordPress content:
| Method | Best for | Includes drafts | Requires auth |
| ---------------- | ------------------------------ | --------------- | ------------- |
| WXR file upload | Complete migrations | Yes | No |
| WordPress.com | WordPress.com hosted sites | Yes | OAuth |
| REST API (probe) | Checking content before export | No | Optional |
The WXR file upload is recommended for most migrations. It captures all content, including drafts, custom fields, and private posts.
## WXR File Import
<Steps>
1. **Export from WordPress**
In your WordPress admin, go to **Tools → Export → All content → Download Export File**.
2. **Open the Import wizard**
In EmDash, go to **Admin → Settings → Import → WordPress**.
3. **Upload your export file**
Drag and drop your `.xml` file or click to browse. The file is parsed in your browser.
4. **Review detected content**
The wizard shows what was found:
```
Found in export:
├── Posts: 127 → posts [New collection]
├── Pages: 12 → pages [Add fields]
└── Media: 89 attachments
```
5. **Configure mappings**
Toggle which post types to import. EmDash automatically:
- Creates new collections for unmapped post types
- Adds missing fields to existing collections
- Warns about field type conflicts
6. **Execute the import**
Click **Import Content**. Progress displays as each item is processed.
7. **Import media (optional)**
After content imports, choose whether to download media files. EmDash:
- Downloads from your WordPress URLs
- Deduplicates by content hash
- Rewrites URLs in your content automatically
</Steps>
<Aside type="tip">
Re-running the import is safe. Items are matched by WordPress ID, so you won't create duplicates.
</Aside>
## Content Conversion
### Gutenberg to Portable Text
EmDash converts Gutenberg blocks to [Portable Text](https://github.com/portabletext/portabletext), a structured content format.
| Gutenberg Block | Portable Text | Notes |
| ---------------- | ---------------------------- | ----------------------------- |
| `core/paragraph` | `block` style="normal" | Inline marks preserved |
| `core/heading` | `block` style="h1-h6" | Level from block attributes |
| `core/image` | `image` block | Media reference updated |
| `core/list` | `block` with `listItem` type | Ordered and unordered |
| `core/quote` | `block` style="blockquote" | Citation included |
| `core/code` | `code` block | Language attribute preserved |
| `core/embed` | `embed` block | URL and provider stored |
| `core/gallery` | `gallery` block | Array of image references |
| `core/columns` | `columns` block | Nested content preserved |
| Unknown blocks | `htmlBlock` | Raw HTML preserved for review |
Unknown blocks are stored as `htmlBlock` with the original HTML and block metadata. You can review and convert these manually or create custom Portable Text components to render them.
### Classic Editor Content
HTML from the Classic Editor is converted to Portable Text blocks. Inline styles (`<strong>`, `<em>`, `<a>`) become marks on spans.
### Status Mapping
| WordPress Status | EmDash Status |
| ---------------- | --------------- |
| `publish` | `published` |
| `draft` | `draft` |
| `pending` | `pending` |
| `private` | `private` |
| `future` | `scheduled` |
| `trash` | `archived` |
## Taxonomy Import
Categories and tags import as taxonomies with hierarchy preserved:
```
WordPress: EmDash:
├── Categories (hierarchical) ├── taxonomies table
│ ├── News │ ├── category/news
│ │ ├── Local │ ├── category/local (parent: news)
│ │ └── World │ ├── category/world (parent: news)
│ └── Sports │ └── category/sports
└── Tags (flat) └── content_taxonomies junction
├── featured ├── tag/featured
└── breaking └── tag/breaking
```
## Custom Fields and ACF
WordPress post meta and ACF fields are analyzed during import:
<Steps>
1. **Analysis phase**
The wizard detects custom fields and suggests EmDash field types:
```
Custom Fields:
├── subtitle (string, 45 posts)
├── _yoast_wpseo_title → seo.title (string, 127 posts)
├── _thumbnail_id → featuredImage (reference, 89 posts)
└── price (number, 23 posts)
```
2. **Field mapping**
Internal WordPress fields (starting with `_edit_`, `_wp_`) are hidden by default. SEO plugin fields map to an `seo` object.
3. **Type inference**
EmDash infers field types from values:
- Numeric strings → `number`
- `"1"`, `"0"`, `"true"`, `"false"` → `boolean`
- ISO dates → `date`
- Serialized PHP/JSON → `json`
- WordPress IDs (e.g., `_thumbnail_id`) → `reference`
</Steps>
<Aside>
ACF repeater fields and flexible content import as JSON. Create matching Portable Text or array
fields in EmDash to structure this data.
</Aside>
## URL Redirects
After import, EmDash generates a redirect map:
```json
{
"redirects": [
{ "from": "/?p=123", "to": "/posts/hello-world" },
{ "from": "/2024/01/hello-world/", "to": "/posts/hello-world" },
{ "from": "/category/news/", "to": "/categories/news" }
],
"feeds": [
{ "from": "/feed/", "to": "/rss.xml" },
{ "from": "/feed/atom/", "to": "/atom.xml" }
]
}
```
Apply these redirects to:
- Cloudflare redirect rules
- Your hosting platform's redirect config
- Astro's `redirects` option in `astro.config.mjs`
## Concept Mapping Reference
Use this table when adapting WordPress patterns to EmDash:
| WordPress | EmDash | Notes |
| ----------------------- | ------------------------------------ | ------------------------------ |
| `register_post_type()` | Collection in admin UI | Created via dashboard or API |
| `register_taxonomy()` | Taxonomy or array field | Depends on complexity |
| `register_meta()` | Field in collection schema | Typed, not key-value |
| `WP_Query` | `getCollection(filters)` | Runtime queries |
| `get_post()` | `getEntry(collection, id)` | Returns entry or null |
| `wp_insert_post()` | `POST /_emdash/api/content/{type}` | REST API |
| `the_content` | `<PortableText value={...} />` | Portable Text rendering |
| `add_shortcode()` | Portable Text custom block | Custom component renderer |
| `register_block_type()` | Portable Text custom block | Same as shortcodes |
| `add_menu_page()` | Plugin admin page | Under `/_emdash/admin/` |
| `add_action/filter()` | Plugin hooks | `hooks.content:beforeSave` |
| `wp_options` | `ctx.kv` | Key-value store |
| `wp_postmeta` | Collection fields | Structured, not key-value |
| `$wpdb` | `ctx.storage` | Direct storage access |
| Categories/Tags | Taxonomies | Hierarchical support preserved |
## API Import (Advanced)
The WordPress import is available through the admin dashboard and the REST API. Use the admin dashboard import wizard for the best experience — it provides field mapping, conflict resolution, and progress tracking.
The import API endpoints are under `/_emdash/api/import/wordpress/` for programmatic access.
## Troubleshooting
### "XML parsing error"
The export file may be corrupted or incomplete. Re-export from WordPress.
### Media download failures
Some images may be behind authentication or have moved. The import continues, and failed URLs are logged for manual handling.
### Field type conflicts
If an existing collection has a field with an incompatible type, the import wizard shows the conflict. Either:
- Rename the EmDash field
- Change the WordPress field mapping
- Delete and recreate the collection
### Large exports
For exports over 100MB, consider:
1. Export post types separately in WordPress
2. Import each file sequentially
3. Use the CLI with `--resume` for reliability
## Next Steps
- **[Content Import](/migration/content-import/)** — Other import sources and methods
- **[Plugin Porting](/migration/plugin-porting/)** — Migrate WordPress plugin functionality
- **[Working with Content](/guides/working-with-content/)** — Query and render your imported content

View File

@@ -0,0 +1,424 @@
---
title: Porting WordPress Plugins
description: Convert WordPress plugins to EmDash plugins using the Plugin API
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Many WordPress plugins can be ported to EmDash. The plugin model is different—TypeScript instead of PHP, hooks instead of actions/filters, structured storage instead of wp_options—but most functionality maps cleanly.
## Portability Assessment
Not all plugins make sense to port. Assess candidates before starting.
<CardGrid>
<Card title="Good candidates" icon="approve-check">
Custom fields, SEO plugins, content processors, admin UI extensions, analytics, social sharing, forms
</Card>
<Card title="Poor candidates" icon="close">
Multisite features, WooCommerce/Gutenberg integrations, plugins that patch WordPress core internals
</Card>
</CardGrid>
## Plugin Structure Comparison
<Tabs>
<TabItem label="WordPress">
```
wp-content/plugins/my-plugin/
├── my-plugin.php # Main file with plugin header
├── includes/
│ ├── class-admin.php
│ └── class-api.php
└── admin/
└── js/
```
</TabItem>
<TabItem label="EmDash">
```
my-plugin/
├── src/
│ ├── index.ts # Plugin definition (definePlugin)
│ └── admin.tsx # Admin UI exports (React)
├── package.json
└── tsconfig.json
```
</TabItem>
</Tabs>
## Hooks Mapping
WordPress uses `add_action()` and `add_filter()` with string hook names. EmDash uses typed hooks declared in the plugin definition.
### Lifecycle Hooks
| WordPress | EmDash | Notes |
| ---------------------------- | ------------------- | ---------------------------------------- |
| `register_activation_hook()` | `plugin:install` | Runs once on first install |
| Plugin enabled | `plugin:activate` | Runs when enabled |
| Plugin disabled | `plugin:deactivate` | Runs when disabled |
| `register_uninstall_hook()` | `plugin:uninstall` | `event.deleteData` indicates user choice |
### Content Hooks
| WordPress | EmDash | Notes |
| --------------------- | ---------------------- | ------------------------------------------ |
| `wp_insert_post_data` | `content:beforeSave` | Return modified content or throw to cancel |
| `save_post` | `content:afterSave` | Side effects after save |
| `before_delete_post` | `content:beforeDelete` | Return `false` to cancel |
| `deleted_post` | `content:afterDelete` | Cleanup after deletion |
<Tabs>
<TabItem label="WordPress">
```php
add_action('save_post', function($post_id, $post, $update) {
if ($post->post_type !== 'product') return;
$price = get_post_meta($post_id, 'price', true);
if ($price > 1000) {
update_post_meta($post_id, 'is_premium', true);
}
}, 10, 3);
````
</TabItem>
<TabItem label="EmDash">
```typescript
hooks: {
"content:afterSave": async (event, ctx) => {
if (event.collection !== "products") return;
const price = event.content.price as number;
if (price > 1000) {
await ctx.kv.set(`premium:${event.content.id}`, true);
}
},
}
````
</TabItem>
</Tabs>
### Media Hooks
| WordPress | EmDash | Notes |
| ---------------------------- | -------------------- | --------------------- |
| `wp_handle_upload_prefilter` | `media:beforeUpload` | Validate or transform |
| `add_attachment` | `media:afterUpload` | React after upload |
## Storage Mapping
### Options API → KV Store
<Tabs>
<TabItem label="WordPress">
```php
$api_key = get_option('my_plugin_api_key', '');
update_option('my_plugin_api_key', 'abc123');
delete_option('my_plugin_api_key');
```
</TabItem>
<TabItem label="EmDash">
```typescript
const apiKey = await ctx.kv.get<string>("settings:apiKey") ?? "";
await ctx.kv.set("settings:apiKey", "abc123");
await ctx.kv.delete("settings:apiKey");
```
</TabItem>
</Tabs>
<Aside>
Use `settings:` prefix for user-configurable values and `state:` prefix for internal plugin state.
</Aside>
### Custom Tables → Storage Collections
<Tabs>
<TabItem label="WordPress">
```php
global $wpdb;
$table = $wpdb->prefix . 'my_plugin_items';
// Insert
$wpdb->insert($table, ['name' => 'Item 1', 'status' => 'active']);
// Query
$items = $wpdb->get_results(
"SELECT \* FROM $table WHERE status = 'active' LIMIT 10"
);
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Declare in plugin definition
storage: {
items: {
indexes: ["status", "createdAt"],
},
},
// In hooks or routes:
await ctx.storage.items.put("item-1", {
name: "Item 1",
status: "active",
createdAt: new Date().toISOString(),
});
const result = await ctx.storage.items.query({
where: { status: "active" },
limit: 10,
});
````
</TabItem>
</Tabs>
## Settings Schema
WordPress uses the Settings API for admin forms. EmDash uses a declarative schema that auto-generates UI.
<Tabs>
<TabItem label="WordPress">
```php
add_action('admin_init', function() {
register_setting('my_plugin', 'my_plugin_api_key');
add_settings_section('main', 'Settings', null, 'my-plugin');
add_settings_field('api_key', 'API Key', function() {
$value = get_option('my_plugin_api_key');
echo '<input type="text" name="my_plugin_api_key"
value="' . esc_attr($value) . '">';
}, 'my-plugin', 'main');
});
```
</TabItem>
<TabItem label="EmDash">
```typescript
admin: {
settingsSchema: {
apiKey: {
type: "secret",
label: "API Key",
description: "Your API key from the dashboard",
},
enabled: {
type: "boolean",
label: "Enabled",
default: true,
},
limit: {
type: "number",
label: "Item Limit",
default: 100,
min: 1,
max: 1000,
},
},
}
```
</TabItem>
</Tabs>
## Admin UI
WordPress admin pages are PHP. EmDash uses React components.
```tsx title="src/admin.tsx"
import { useState, useEffect } from "react";
export const widgets = {
summary: function SummaryWidget() {
const [count, setCount] = useState(0);
useEffect(() => {
fetch("/_emdash/api/plugins/my-plugin/status")
.then((r) => r.json())
.then((data) => setCount(data.count));
}, []);
return <div>Total items: {count}</div>;
},
};
export const pages = {
settings: function SettingsPage() {
// React component for settings page
return <div>Settings content</div>;
},
};
```
Register in the plugin definition:
```typescript title="src/index.ts"
admin: {
entry: "@my-org/my-plugin/admin",
pages: [{ path: "/settings", label: "Dashboard" }],
widgets: [{ id: "summary", title: "Summary", size: "half" }],
},
```
## REST API → Plugin Routes
<Tabs>
<TabItem label="WordPress">
```php
register_rest_route('my-plugin/v1', '/items', [
'methods' => 'GET',
'callback' => function($request) {
global $wpdb;
$items = $wpdb->get_results("SELECT * FROM items LIMIT 50");
return new WP_REST_Response($items);
},
]);
```
</TabItem>
<TabItem label="EmDash">
```typescript
routes: {
items: {
handler: async (ctx) => {
const result = await ctx.storage.items.query({ limit: 50 });
return { items: result.items };
},
},
},
```
</TabItem>
</Tabs>
Routes are available at `/_emdash/api/plugins/{plugin-id}/{route-name}`.
## Porting Process
<Steps>
1. **Analyze the WordPress plugin**
Document what it does: hooks, database operations, admin pages, REST endpoints.
2. **Map to EmDash concepts**
WordPress hooks → EmDash hooks. `wp_options` → `ctx.kv`. Custom tables → Storage collections. Admin pages → React components. REST endpoints → Plugin routes.
3. **Create the plugin skeleton**
```typescript title="src/index.ts"
import { definePlugin } from "emdash";
export function createPlugin() {
return definePlugin({
id: "my-ported-plugin",
version: "1.0.0",
capabilities: [],
storage: {},
hooks: {},
routes: {},
admin: {},
});
}
```
4. **Implement in order**
Storage → Hooks → Admin UI → Routes
5. **Test thoroughly**
Verify hooks fire correctly, storage works, and admin UI renders.
</Steps>
## Example: Read Time Plugin
<Tabs>
<TabItem label="WordPress">
```php
add_filter('wp_insert_post_data', function($data, $postarr) {
if ($data['post_type'] !== 'post') return $data;
$content = strip_tags($data['post_content']);
$word_count = str_word_count($content);
$read_time = ceil($word_count / 200);
if (!empty($postarr['ID'])) {
update_post_meta($postarr['ID'], '_read_time', $read_time);
}
return $data;
}, 10, 2);
````
</TabItem>
<TabItem label="EmDash">
```typescript title="src/index.ts"
export function createPlugin() {
return definePlugin({
id: "read-time",
version: "1.0.0",
admin: {
settingsSchema: {
wordsPerMinute: {
type: "number",
label: "Words per minute",
default: 200,
min: 100,
max: 400,
},
},
},
hooks: {
"content:beforeSave": async (event, ctx) => {
if (event.collection !== "posts") return;
const wpm = await ctx.kv.get<number>("settings:wordsPerMinute") ?? 200;
const text = JSON.stringify(event.content.body || "");
const readTime = Math.ceil(text.split(/\s+/).length / wpm);
return { ...event.content, readTime };
},
},
});
}
````
</TabItem>
</Tabs>
<Aside type="tip" title="AI-Assisted Porting">
Plugin porting is more nuanced than theme porting, but AI agents still help significantly. Provide
the WordPress plugin code along with EmDash's Plugin API documentation, and the agent can
generate a reasonable first draft. Complex plugins may need multiple iterations.
</Aside>
## Capabilities
Plugins must declare required capabilities for security sandboxing:
| Capability | Provides | Use Case |
| ----------------- | ----------------------------- | ------------------- |
| `network:request` | `ctx.http.fetch()` | External API calls |
| `content:read` | `ctx.content.get()`, `list()` | Reading CMS content |
| `content:write` | `ctx.content.create()`, etc. | Modifying content |
| `media:read` | `ctx.media.get()`, `list()` | Reading media |
| `media:write` | `ctx.media.getUploadUrl()` | Uploading media |
## Common Gotchas
**No global state** — Use storage instead of global variables.
**Async everything** — Always `await` storage and API calls.
**No direct SQL** — Use structured storage collections.
**No file system** — Use the media API for files.
## Next Steps
- [Hooks Reference](/plugins/hooks/) — All hooks with signatures
- [Storage API](/plugins/storage/) — Collections and queries
- [Settings](/plugins/settings/) — Settings schema and KV store
- [Admin UI](/plugins/admin-ui/) — Building admin pages

View File

@@ -0,0 +1,401 @@
---
title: Admin UI
description: Add admin pages and dashboard widgets to the EmDash admin panel.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Plugins can extend the admin panel with custom pages and dashboard widgets. These are React components that render alongside core admin functionality.
## Admin Entry Point
Plugins with admin UI export components from an `admin` entry point:
```typescript title="src/admin.tsx"
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
// Dashboard widgets
export const widgets = {
"seo-overview": SEODashboardWidget,
};
// Admin pages
export const pages = {
"/settings": SEOSettingsPage,
};
```
Configure the entry point in `package.json`:
```json title="package.json"
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}
```
Reference it in your plugin definition:
```typescript title="src/index.ts"
definePlugin({
id: "seo",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-seo/admin",
pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
},
});
```
## Admin Pages
Admin pages are React components that receive the plugin context via hooks.
### Page Definition
Define pages in `admin.pages`:
```typescript
admin: {
pages: [
{
path: "/settings", // URL path (relative to plugin base)
label: "Settings", // Sidebar label
icon: "settings", // Icon name (optional)
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
];
}
```
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`.
### Page Component
```typescript title="src/components/SettingsPage.tsx"
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/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>Plugin Settings</h1>
<label>
Site Title
<input
type="text"
value={settings.siteTitle || ""}
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
/>
</label>
<label>
<input
type="checkbox"
checked={settings.enabled ?? true}
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
/>
Enabled
</label>
<button onClick={handleSave} disabled={saving}>
{saving ? "Saving..." : "Save Settings"}
</button>
</div>
);
}
```
### Plugin API Hook
Use `usePluginAPI()` to call your plugin's routes:
```typescript
import { usePluginAPI } from "@emdash-cms/admin";
function MyComponent() {
const api = usePluginAPI();
// GET request to plugin route
const data = await api.get("status");
// POST request with body
await api.post("settings/save", { enabled: true });
// With URL parameters
const result = await api.get("history?limit=50");
}
```
The hook automatically adds the plugin ID prefix to route URLs.
## Dashboard Widgets
Widgets appear on the admin dashboard and provide at-a-glance information.
### Widget Definition
Define widgets in `admin.widgets`:
```typescript
admin: {
widgets: [
{
id: "seo-overview", // Unique widget ID
title: "SEO Overview", // Widget title (optional)
size: "half", // "full" | "half" | "third"
},
];
}
```
### Widget Component
```typescript title="src/components/SEOWidget.tsx"
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdash-cms/admin";
export function SEOWidget() {
const api = usePluginAPI();
const [data, setData] = useState({ score: 0, issues: [] });
useEffect(() => {
api.get("analyze").then(setData);
}, []);
return (
<div className="widget-content">
<div className="score">{data.score}%</div>
<ul>
{data.issues.map((issue, i) => (
<li key={i}>{issue.message}</li>
))}
</ul>
</div>
);
}
```
### Widget Sizes
| Size | Description |
| ------- | ------------------------- |
| `full` | Full dashboard width |
| `half` | Half dashboard width |
| `third` | One-third dashboard width |
Widgets wrap automatically based on screen width.
## Export Structure
The admin entry point exports two objects:
```typescript title="src/admin.tsx"
import { SettingsPage } from "./components/SettingsPage";
import { ReportsPage } from "./components/ReportsPage";
import { StatusWidget } from "./components/StatusWidget";
import { OverviewWidget } from "./components/OverviewWidget";
// Pages keyed by path
export const pages = {
"/settings": SettingsPage,
"/reports": ReportsPage,
};
// Widgets keyed by ID
export const widgets = {
status: StatusWidget,
overview: OverviewWidget,
};
```
<Aside type="caution">
Page paths in `pages` must match the `path` values in `admin.pages`. Widget keys must match the
`id` values in `admin.widgets`.
</Aside>
## Using Admin Components
EmDash provides pre-built components for common patterns:
```typescript
import {
Card,
Button,
Input,
Select,
Toggle,
Table,
Pagination,
Alert,
Loading
} from "@emdash-cms/admin";
function SettingsPage() {
return (
<Card title="Settings">
<Input label="API Key" type="password" />
<Toggle label="Enabled" defaultChecked />
<Button variant="primary">Save</Button>
</Card>
);
}
```
## Auto-Generated Settings UI
If your plugin only needs a settings form, use `admin.settingsSchema` without custom components:
```typescript
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
enabled: { type: "boolean", label: "Enabled", default: true }
}
}
```
EmDash generates a settings page automatically. Add custom pages only for functionality beyond basic settings.
## Navigation
Plugin pages appear in the admin sidebar under the plugin name. The order matches the `admin.pages` array.
```typescript
admin: {
pages: [
{ path: "/settings", label: "Settings", icon: "settings" }, // First
{ path: "/history", label: "History", icon: "history" }, // Second
{ path: "/reports", label: "Reports", icon: "chart" }, // Third
];
}
```
## Build Configuration
Admin components need a separate build entry point. Configure your bundler:
<Tabs>
<TabItem label="tsdown">
```typescript title="tsdown.config.ts"
export default {
entry: {
index: "src/index.ts",
admin: "src/admin.tsx"
},
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
};
```
</TabItem>
<TabItem label="tsup">
```typescript title="tsup.config.ts"
export default {
entry: ["src/index.ts", "src/admin.tsx"],
format: "esm",
dts: true,
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
};
```
</TabItem>
</Tabs>
Keep React and EmDash admin as external dependencies to avoid bundling duplicates.
## Plugin Enable/Disable
When a plugin is disabled in the admin:
- Sidebar links are hidden
- Dashboard widgets are not rendered
- Admin pages return 404
- Backend hooks still execute (for data safety)
Plugins can check their enabled state:
```typescript
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");
```
## Example: Complete Admin UI
```typescript title="src/index.ts"
import { definePlugin } from "emdash";
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["network:request"],
allowedHosts: ["api.analytics.example.com"],
storage: {
events: { indexes: ["type", "createdAt"] },
},
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
pages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events!.count({
createdAt: { gte: today },
});
return { today: count };
},
},
},
});
```
```typescript title="src/admin.tsx"
import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";
export const widgets = {
"events-today": EventsWidget,
};
export const pages = {
"/dashboard": DashboardPage,
"/settings": SettingsPage,
};
```

View File

@@ -0,0 +1,471 @@
---
title: Plugin API Routes
description: Expose REST endpoints from your plugin for admin UI and external integrations.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Plugins can expose API routes for their admin UI components or external integrations. Routes receive the full plugin context and can access storage, KV, content, and media. Both native and sandboxed plugins support API routes.
<Aside type="note">
Native and sandboxed plugins use slightly different handler signatures for routes. Native-format plugins receive a single `RouteContext` argument. Standard-format plugins (which can run in either mode) receive two arguments: `(routeCtx, pluginCtx)`. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for details on the two formats.
</Aside>
## Defining Routes
Define routes in the `routes` object:
```typescript
import { definePlugin } from "emdash";
import { z } from "astro/zod";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
routes: {
// Simple route
status: {
handler: async (ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
// 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,
};
},
},
},
});
```
## Route URLs
Routes mount at `/_emdash/api/plugins/<plugin-id>/<route-name>`:
| 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` |
Route names can include slashes for nested paths.
## Route Handler
The handler receives a `RouteContext` with the plugin context plus request-specific data:
```typescript
interface RouteContext extends PluginContext {
input: TInput; // Validated input (from body or query params)
request: Request; // Original Request object
}
```
### Return Values
Return any JSON-serializable value:
```typescript
// Object
return { success: true, data: items };
// Array
return items;
// Primitive
return 42;
```
<Aside type="note">
Routes always return JSON. The response has `Content-Type: application/json`.
</Aside>
### Errors
Throw to return an error response:
```typescript
handler: async (ctx) => {
const item = await ctx.storage.items!.get(ctx.input.id);
if (!item) {
throw new Error("Item not found");
// Returns: { "error": "Item not found" } with 500 status
}
return item;
};
```
For custom status codes, throw a `Response`:
```typescript
handler: async (ctx) => {
const item = await ctx.storage.items!.get(ctx.input.id);
if (!item) {
throw new Response(JSON.stringify({ error: "Not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
return item;
};
```
## Input Validation
Use Zod schemas to validate and parse input:
```typescript
import { z } from "astro/zod";
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, tags } = ctx.input;
await ctx.storage.items!.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString()
});
return { success: true };
}
}
}
```
Invalid input returns a 400 error with validation details.
### Input Sources
Input is parsed from:
1. **POST/PUT/PATCH** — Request body (JSON)
2. **GET/DELETE** — URL query parameters
```typescript
// POST /plugins/forms/create
// Body: { "title": "Hello", "email": "user@example.com" }
// GET /plugins/forms/list?limit=20&status=pending
```
## HTTP Methods
Routes respond to all HTTP methods. Check `ctx.request.method` to handle them differently:
```typescript
routes: {
item: {
input: z.object({
id: z.string()
}),
handler: async (ctx) => {
const { id } = ctx.input;
switch (ctx.request.method) {
case "GET":
return await ctx.storage.items!.get(id);
case "DELETE":
await ctx.storage.items!.delete(id);
return { deleted: true };
default:
throw new Response("Method not allowed", { status: 405 });
}
}
}
}
```
## Accessing the Request
The full `Request` object is available for advanced use cases:
```typescript
handler: async (ctx) => {
const { request } = ctx;
// Headers
const auth = request.headers.get("Authorization");
// URL parameters
const url = new URL(request.url);
const page = url.searchParams.get("page");
// Method
if (request.method !== "POST") {
throw new Response("POST required", { status: 405 });
}
// Body (if not using input schema)
const body = await request.json();
};
```
## Common Patterns
### Settings Routes
Expose and update plugin settings:
```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": {
input: z.object({
enabled: z.boolean().optional(),
apiKey: z.string().optional(),
maxItems: z.number().optional()
}),
handler: async (ctx) => {
const input = ctx.input;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
}
}
}
```
### Paginated List
Return paginated results with cursor-based navigation:
```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
Proxy requests to external services (requires `network:request` capability):
```typescript
definePlugin({
id: "weather",
version: "1.0.0",
capabilities: ["network:request"],
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(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
});
```
### Action Endpoint
Trigger a one-off action:
```typescript
routes: {
sync: {
handler: async (ctx) => {
ctx.log.info("Starting sync...");
const startTime = Date.now();
let synced = 0;
// Do work...
const items = await fetchExternalItems(ctx);
for (const item of items) {
await ctx.storage.items!.put(item.id, item);
synced++;
}
const duration = Date.now() - startTime;
ctx.log.info("Sync complete", { synced, duration });
return {
success: true,
synced,
duration,
};
};
}
}
```
## Calling Routes from Admin UI
Use the `usePluginAPI()` hook in admin components:
```typescript
import { usePluginAPI } from "@emdash-cms/admin";
function SettingsPage() {
const api = usePluginAPI();
const handleSave = async (settings) => {
await api.post("settings/save", settings);
};
const loadSettings = async () => {
return api.get("settings");
};
}
```
The hook automatically prefixes the plugin ID to route URLs.
## Calling Routes Externally
Routes are accessible at their full URL:
```bash
# GET request
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
# POST request
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "user@example.com"}'
```
<Aside type="caution">
Plugin routes don't have built-in authentication. For public endpoints, implement your own auth
checks. For admin-only routes, the admin session middleware provides protection.
</Aside>
## Route Context Reference
```typescript
interface RouteContext<TInput = unknown> extends PluginContext {
/** Validated input from request body or query params */
input: TInput;
/** Original request object */
request: Request;
/** Plugin metadata */
plugin: { id: string; version: string };
/** Plugin storage collections */
storage: Record<string, StorageCollection>;
/** Key-value store */
kv: KVAccess;
/** Content access (if capability declared) */
content?: ContentAccess;
/** Media access (if capability declared) */
media?: MediaAccess;
/** HTTP client (if capability declared) */
http?: HttpAccess;
/** Structured logger */
log: LogAccess;
}
```

View File

@@ -0,0 +1,146 @@
---
title: Block Kit
description: Declarative UI blocks for sandboxed plugin admin pages and widgets.
---
import { Aside } from "@astrojs/starlight/components";
EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The host renders the blocks — no plugin-supplied JavaScript ever runs in the browser.
<Aside>
Native plugins (declared in `astro.config.ts`) can still ship custom React components. Block Kit is the admin UI system for sandboxed plugins -- no plugin-supplied JavaScript ever runs in the browser.
</Aside>
<Aside type="tip">
Block Kit elements are also used for [Portable Text block editing fields](/plugins/creating-plugins/#portable-text-block-types). When a plugin declares `fields` on a block type, the editor renders a Block Kit form for editing block data (URL, title, parameters, etc.).
</Aside>
## How it works
1. User navigates to a plugin's admin page
2. The admin sends a `page_load` interaction to the plugin's admin route
3. The plugin returns a `BlockResponse` containing an array of blocks
4. The admin renders the blocks using the `BlockRenderer` component
5. When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin
6. The plugin returns new blocks, and the cycle repeats
```typescript
// Plugin admin route handler
routes: {
admin: {
handler: async (ctx, { request }) => {
const interaction = await 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 |
| `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons |
| `accordion` | Collapsible section wrapping nested blocks |
## 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 |
## Builder helpers
The `@emdash-cms/blocks` package exports builder helpers for cleaner code:
```typescript
import { blocks, elements } from "@emdash-cms/blocks";
const { header, form, section, stats } = blocks;
const { textInput, toggle, select, button } = elements;
return {
blocks: [
header("SEO 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" },
}),
],
};
```
## Conditional fields
Form fields can be conditionally shown based on other field values:
```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 }
}
```
The `api_key` field only appears when `auth_enabled` is toggled on. Conditions are evaluated client-side with no round-trip.
## Try it
Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) to interactively build and test block layouts.

View File

@@ -0,0 +1,479 @@
---
title: Creating Plugins
description: Build an EmDash plugin with hooks, storage, settings, and admin UI.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
This guide walks through building a complete EmDash plugin. You'll learn how to structure the code, define hooks and storage, and export admin UI components.
<Aside type="tip">
**Which format should I use?** Plugins come in two formats: **standard** (works in both sandboxed and native mode) and **native** (native mode only). Use standard format unless you need React admin pages, Portable Text rendering components, or page fragment injection. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for a full comparison.
</Aside>
This page covers native-format plugins with the full descriptor + definition structure. For standard-format plugins that can be published to the marketplace, see [Publishing Plugins](/plugins/publishing/).
## Plugin Structure
Every native plugin has two parts that **run in different contexts**:
1. **Plugin descriptor** (`PluginDescriptor`) — returned by the factory function, tells EmDash how to load the plugin. **Runs at build time in Vite** (imported in `astro.config.mjs`). Must be side-effect-free and cannot use runtime APIs.
2. **Plugin definition** (`definePlugin()`) — contains the runtime logic (hooks, routes, storage). **Runs at request time on the deployed server.** Has access to the full plugin context (`ctx`).
These must be in **separate entrypoints** because they execute in completely different environments:
```
my-plugin/
├── src/
│ ├── descriptor.ts # Plugin descriptor (runs in Vite at build time)
│ ├── index.ts # Plugin definition with definePlugin() (runs at deploy time)
│ ├── admin.tsx # Admin UI exports (React components) — optional
│ └── astro/ # Optional: Astro components for site-side rendering
│ └── index.ts # Must export `blockComponents`
├── package.json
└── tsconfig.json
```
## Creating the Plugin
### Descriptor (build time)
The descriptor tells EmDash where to find the plugin and what admin UI it provides. This file is imported in `astro.config.mjs` and runs in Vite.
```typescript title="src/descriptor.ts"
import type { PluginDescriptor } from "emdash";
// Options your plugin accepts at registration time
export interface MyPluginOptions {
enabled?: boolean;
maxItems?: number;
}
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
return {
id: "my-plugin",
version: "1.0.0",
entrypoint: "@my-org/plugin-example",
options,
adminEntry: "@my-org/plugin-example/admin",
componentsEntry: "@my-org/plugin-example/astro",
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
};
}
```
### Definition (runtime)
The definition contains the runtime logic — hooks, routes, storage, and admin configuration. This file is loaded at request time on the deployed server.
```typescript title="src/index.ts"
import { definePlugin } from "emdash";
import type { MyPluginOptions } from "./descriptor.js";
export function createPlugin(options: MyPluginOptions = {}) {
const maxItems = options.maxItems ?? 100;
return definePlugin({
id: "my-plugin",
version: "1.0.0",
// Declare required capabilities
capabilities: ["content:read"],
// Plugin storage (document collections)
storage: {
items: {
indexes: ["status", "createdAt", ["status", "createdAt"]],
},
},
// Admin UI configuration
admin: {
entry: "@my-org/plugin-example/admin",
settingsSchema: {
maxItems: {
type: "number",
label: "Maximum Items",
description: "Limit stored items",
default: maxItems,
min: 1,
max: 1000,
},
enabled: {
type: "boolean",
label: "Enabled",
default: options.enabled ?? true,
},
},
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
},
// Hook handlers
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Plugin installed");
},
"content:afterSave": async (event, ctx) => {
const enabled = await ctx.kv.get<boolean>("settings:enabled");
if (enabled === false) return;
ctx.log.info("Content saved", {
collection: event.collection,
id: event.content.id,
});
},
},
// API routes
routes: {
status: {
handler: async (ctx) => {
const count = await ctx.storage.items!.count();
return { count, maxItems };
},
},
},
});
}
export default createPlugin;
```
## Plugin ID Rules
The `id` field must follow these rules:
- Lowercase alphanumeric characters and hyphens only
- Either simple (`my-plugin`) or scoped (`@my-org/my-plugin`)
- Unique across all installed plugins
```typescript
// Valid IDs
"seo";
"audit-log";
"@emdash-cms/plugin-forms";
// Invalid IDs
"MyPlugin"; // No uppercase
"my_plugin"; // No underscores
"my.plugin"; // No dots
```
## Version Format
Use semantic versioning:
```typescript
version: "1.0.0"; // Valid
version: "1.2.3-beta"; // Valid (prerelease)
version: "1.0"; // Invalid (missing patch)
```
## Package Exports
Configure `package.json` exports so EmDash can load each entrypoint. The descriptor and definition are separate exports because they run in different environments:
```json title="package.json"
{
"name": "@my-org/plugin-example",
"version": "1.0.0",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./descriptor": {
"types": "./dist/descriptor.d.ts",
"import": "./dist/descriptor.js"
},
"./admin": {
"types": "./dist/admin.d.ts",
"import": "./dist/admin.js"
},
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
},
"files": ["dist"],
"peerDependencies": {
"emdash": "^0.1.0",
"react": "^18.0.0"
}
}
```
| Export | Context | Purpose |
|--------|---------|---------|
| `"."` | Server (runtime) | `createPlugin()` / `definePlugin()` — loaded by `entrypoint` at request time |
| `"./descriptor"` | Vite (build time) | `PluginDescriptor` factory — imported in `astro.config.mjs` |
| `"./admin"` | Browser | React components for admin pages/widgets |
| `"./astro"` | Server (SSR) | Astro components for site-side block rendering |
Only include `./admin` and `./astro` exports if the plugin uses them.
<Aside type="tip">
Keep `emdash` and `react` as peer dependencies. This prevents version conflicts when the plugin
is installed in a site.
</Aside>
## Complete Example: Audit Log Plugin
This example demonstrates storage, lifecycle hooks, content hooks, and API routes:
```typescript title="src/index.ts"
import { definePlugin } from "emdash";
interface AuditEntry {
timestamp: string;
action: "create" | "update" | "delete";
collection: string;
resourceId: string;
userId?: string;
}
export function createPlugin() {
return definePlugin({
id: "audit-log",
version: "0.1.0",
storage: {
entries: {
indexes: [
"timestamp",
"action",
"collection",
["collection", "timestamp"],
["action", "timestamp"],
],
},
},
admin: {
settingsSchema: {
retentionDays: {
type: "number",
label: "Retention (days)",
description: "Days to keep entries. 0 = forever.",
default: 90,
min: 0,
max: 365,
},
},
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
},
hooks: {
"plugin:install": async (_event, ctx) => {
ctx.log.info("Audit log plugin installed");
},
"content:afterSave": {
priority: 200, // Run after other plugins
timeout: 2000,
handler: async (event, ctx) => {
const { content, collection, isNew } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: isNew ? "create" : "update",
collection,
resourceId: content.id as string,
};
const entryId = `${Date.now()}-${content.id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
},
},
"content:afterDelete": {
priority: 200,
timeout: 1000,
handler: async (event, ctx) => {
const { id, collection } = event;
const entry: AuditEntry = {
timestamp: new Date().toISOString(),
action: "delete",
collection,
resourceId: id,
};
const entryId = `${Date.now()}-${id}`;
await ctx.storage.entries!.put(entryId, entry);
ctx.log.info(`Logged delete on ${collection}/${id}`);
},
},
},
routes: {
recent: {
handler: async (ctx) => {
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit: 10,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
};
},
},
history: {
handler: async (ctx) => {
const url = new URL(ctx.request.url);
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
const cursor = url.searchParams.get("cursor") || undefined;
const result = await ctx.storage.entries!.query({
orderBy: { timestamp: "desc" },
limit,
cursor,
});
return {
entries: result.items.map((item) => ({
id: item.id,
...(item.data as AuditEntry),
})),
cursor: result.cursor,
hasMore: result.hasMore,
};
},
},
},
});
}
export default createPlugin;
```
## Testing Plugins
Test plugins by creating a minimal Astro site with the plugin registered:
1. Create a test site with EmDash installed.
2. Register your plugin in `astro.config.mjs`:
```typescript
import myPlugin from "../path/to/my-plugin/src";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()],
}),
],
});
```
3. Run the dev server and trigger hooks by creating/updating content.
4. Check the console for `ctx.log` output and verify storage via API routes.
For unit tests, mock the `PluginContext` interface and call hook handlers directly.
## Portable Text Block Types
Plugins can add custom block types to the Portable Text editor. These appear in the editor's slash command menu and can be inserted into any `portableText` field.
### Declaring block types
In `createPlugin()`, declare blocks under `admin.portableTextBlocks`:
```typescript title="src/index.ts"
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video", // Named icon: video, code, link, link-external
placeholder: "Paste YouTube URL...",
fields: [ // Block Kit fields for the editing UI
{ 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" },
],
},
],
}
```
Each block type defines:
- **`type`** — Block type name (used in Portable Text `_type`)
- **`label`** — Display name in the slash command menu
- **`icon`** — Icon key (`video`, `code`, `link`, `link-external`). Falls back to a generic cube.
- **`placeholder`** — Input placeholder text
- **`fields`** — Block Kit form fields for editing. If omitted, a simple URL input is shown.
### Site-side rendering
To render your block types on the site, export Astro components from a `componentsEntry`:
```typescript title="src/astro/index.ts"
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// This export name is required — the virtual module imports it
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
```
Set `componentsEntry` in your plugin descriptor:
```typescript
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
// ...
};
}
```
Plugin block components are automatically merged into `<PortableText>` — site authors don't need to import anything. User-provided components take precedence over plugin defaults.
<Aside type="tip">
The embeds plugin (`@emdash-cms/plugin-embeds`) is a complete example of this pattern. It provides
YouTube, Vimeo, Tweet, Bluesky, Mastodon, Gist, and Link Preview block types with both admin
editing fields and site-side Astro rendering components.
</Aside>
### Package exports
Add the `./astro` export to `package.json`:
```json title="package.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" }
}
}
```
## Next Steps
- [Hooks Reference](/plugins/hooks/) — All available hooks with signatures
- [Storage API](/plugins/storage/) — Document collections and queries
- [Settings](/plugins/settings/) — Settings schema and KV store
- [Admin UI](/plugins/admin-ui/) — Pages and widgets
- [API Routes](/plugins/api-routes/) — REST endpoints

View File

@@ -0,0 +1,243 @@
---
title: Field Kit
description: Composable field widgets for json fields, configured through seed options.
---
import { Aside } from "@astrojs/starlight/components";
EmDash's `json` field type stores arbitrary structured data, but the default editor is a single-line text input where you have to type raw JSON by hand. **Field Kit** is a first-party plugin that ships four composable widgets for `json` fields, configured entirely through seed `options` — no React required from site builders.
<Aside type="tip">
Field Kit widgets store **clean JSON**: removing the plugin leaves valid data in the database. No shape mutation, no new columns, no migration.
</Aside>
## Installation
```bash
npm i @emdash-cms/plugin-field-kit
```
Register the plugin in `astro.config.mjs`:
```typescript
import { defineConfig } from "astro/config";
import emdash from "emdash";
import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";
export default defineConfig({
integrations: [
emdash({
plugins: [fieldKitPlugin()],
}),
],
});
```
Then attach a widget to any `json` field by setting `widget` to `field-kit:<name>`:
```json
{
"slug": "ingredients",
"type": "json",
"widget": "field-kit:list",
"options": { "fields": [...] }
}
```
## Widgets
| Widget | Use for | Stored value |
|--------|---------|--------------|
| `object-form` | Inline form for flat JSON objects | `{ key: value, ... }` |
| `list` | Ordered array editor with add / remove / reorder | `[{ ... }, ...]` |
| `grid` | Rows × columns matrix | `{ rowKey: { colKey: value } }` |
| `tags` | Free-form chip/tag input | `["tag1", "tag2"]` |
If a widget is missing its required `options` (e.g. `fields` for `object-form`/`list`, or `rows`/`columns` for `grid`), the editor renders an inline "Widget misconfigured" warning instead of a broken input — useful while iterating on seed schemas.
### object-form
Renders a group of typed sub-fields that store as a single JSON object. Good for fixed-shape structured data like nutrition facts or contact info.
```json
{
"slug": "nutrition",
"type": "json",
"widget": "field-kit:object-form",
"options": {
"collapsed": false,
"fields": [
{ "key": "calories", "label": "Calories", "type": "number", "suffix": "kcal" },
{ "key": "protein", "label": "Protein", "type": "number", "suffix": "g" },
{ "key": "fat", "label": "Fat", "type": "number", "suffix": "g" },
{ "key": "carbs", "label": "Carbs", "type": "number", "suffix": "g" }
]
}
}
```
Stored value: `{ "calories": 250, "protein": 12.5, "fat": 8, "carbs": 30 }`.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions — see [Sub-fields](#sub-fields). |
| `collapsed` | `boolean` | `false` | Render the group collapsed by default. |
| `helpText` | `string` | — | Help text shown below the widget. |
### list
An ordered array editor with add, remove, and reorder controls. Each row is a JSON object whose shape is defined by `fields`. The row header shows a summary rendered from a Mustache-style template.
```json
{
"slug": "ingredients",
"type": "json",
"widget": "field-kit:list",
"options": {
"itemLabel": "Ingredient",
"min": 1,
"max": 50,
"sortable": true,
"summary": "{{name}} — {{amount}}",
"fields": [
{ "key": "name", "label": "Name", "type": "text", "required": true },
{ "key": "amount", "label": "Amount", "type": "text" },
{ "key": "optional", "label": "Optional", "type": "boolean" }
]
}
}
```
Stored value:
```json
[
{ "name": "Flour", "amount": "500g", "optional": false },
{ "name": "Butter", "amount": "200g", "optional": false }
]
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions for each row. |
| `itemLabel` | `string` | `"Item"` | Singular label for a row (used in the "Add" button and fallback row titles). |
| `min` | `number` | — | Minimum number of items. Below this, the remove button hides. |
| `max` | `number` | — | Maximum number of items. At this count, the add button hides. |
| `sortable` | `boolean` | `true` | Show up/down reorder buttons. |
| `summary` | `string` | — | Mustache template rendered as the collapsed-row title. See [Summary templates](#summary-templates). |
| `helpText` | `string` | — | Help text shown below the widget. |
### grid
A two-dimensional matrix of rows × columns. Each cell can be a toggle, text input, number input, or select. Useful for matrices like seasonal availability, price tables, or feature comparisons.
```json
{
"slug": "availability",
"type": "json",
"widget": "field-kit:grid",
"options": {
"cell": "toggle",
"rows": [
{ "key": "berries", "label": "Berries" },
{ "key": "stoneFruit", "label": "Stone fruit" },
{ "key": "citrus", "label": "Citrus" }
],
"columns": [
{ "key": "spring", "label": "Spring" },
{ "key": "summer", "label": "Summer" },
{ "key": "autumn", "label": "Autumn" },
{ "key": "winter", "label": "Winter" }
]
}
}
```
Stored value:
```json
{
"berries": { "spring": false, "summer": true, "autumn": false, "winter": false },
"stoneFruit": { "spring": false, "summer": true, "autumn": true, "winter": false },
"citrus": { "spring": false, "summer": false, "autumn": true, "winter": true }
}
```
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `rows` | `GridAxisDef[]` | _(required)_ | Row definitions: `{ key, label, image? }`. |
| `columns` | `GridAxisDef[]` | _(required)_ | Column definitions: `{ key, label, image? }`. |
| `cell` | `"toggle"` \| `"text"` \| `"number"` \| `"select"` | `"toggle"` | Cell input type, applied uniformly to every cell. |
| `cellOptions` | `string[]` \| `Array<{ label, value }>` | `[]` | Required when `cell` is `"select"`. |
| `helpText` | `string` | — | Help text shown below the widget. |
### tags
A chip-style input for arrays of strings. Supports a fixed `suggestions` list, free-form custom values (toggleable), case transforms, and an optional `max`.
```json
{
"slug": "keywords",
"type": "json",
"widget": "field-kit:tags",
"options": {
"placeholder": "Add a keyword…",
"max": 10,
"transform": "lowercase",
"allowCustom": true,
"suggestions": ["vegan", "vegetarian", "gluten-free", "dairy-free", "nut-free"]
}
}
```
Stored value: `["vegan", "gluten-free"]`.
Press <kbd>Enter</kbd> or `,` to commit a tag. <kbd>Backspace</kbd> on an empty input removes the last tag. Duplicate tags are silently ignored.
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `placeholder` | `string` | `"Add..."` | Input placeholder shown when no tags are present. |
| `max` | `number` | — | Maximum number of tags. The input hides at the limit. |
| `suggestions` | `string[]` | `[]` | Autocomplete suggestions surfaced via a `<datalist>`. |
| `allowCustom` | `boolean` | `true` | When `false`, only values from `suggestions` can be added. |
| `transform` | `"none"` \| `"lowercase"` \| `"uppercase"` \| `"trim"` | `"none"` | Normalize tags as they're added. |
| `helpText` | `string` | — | Help text shown below the widget. |
## Sub-fields
`object-form` and `list` accept an `options.fields` array of typed sub-field definitions. Each entry has a `key` (the JSON object key it writes to), a `label`, a `type`, and type-specific extras.
| Sub-field type | Renders as | Notable extras |
|----------------|------------|----------------|
| `text` | Single-line input | `placeholder` |
| `textarea` | Multi-line input | `rows` (default `3`), `placeholder` |
| `number` | Numeric input | `min`, `max`, `step`, `prefix`, `suffix`, `placeholder` |
| `boolean` | Toggle switch | — |
| `select` | Dropdown | `options: string[] \| Array<{ label, value }>`, `placeholder` |
| `date` | Date input | — |
| `color` | Native color picker paired with a hex text input | — |
| `url` | URL input (HTML5 `type="url"`) | `placeholder` |
Common props on every sub-field: `required`, `helpText`, `defaultValue`.
## Summary templates
The `list` widget renders each collapsed row using a Mustache-style template in `options.summary`. `{{key}}` is replaced with the row's value for that key (coerced to a string). Falsy values fall back to `"{itemLabel} {n}"`.
```
"summary": "{{name}} — {{amount}}"
```
Renders rows like `Flour — 500g`. The template is plain string substitution — no HTML, no nested expressions.
## Data durability
Field Kit widgets store plain JSON in the field's existing column. There are no plugin-specific tables, no foreign keys, no schema mutation. If you remove `@emdash-cms/plugin-field-kit` from your config, the data stays valid — only the editing UI changes back to the default `json` text input.
This applies even when you change the widget shape: unknown keys on stored objects are preserved on the next write, so you can evolve a schema without losing data captured under an older field set.
## See also
- [Plugin Overview](/plugins/overview/) — how EmDash plugins work.
- [Creating Plugins](/plugins/creating-plugins/) — write your own field widgets if Field Kit doesn't fit.
- [Discussion #571](https://github.com/emdash-cms/emdash/discussions/571) — the proposal that led to this plugin.

View File

@@ -0,0 +1,580 @@
---
title: Plugin Hooks
description: Hook into content, media, and plugin lifecycle events.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Hooks let plugins run code in response to events. All hooks receive an event object and the plugin context. Hooks are declared at plugin definition time, not registered dynamically at runtime.
## Hook Signature
Every hook handler receives two arguments:
```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```
- `event` — Data about the event (content being saved, media uploaded, etc.)
- `ctx` — The [plugin context](/plugins/overview/#plugin-context) with storage, KV, logging, and capability-gated APIs
## Hook Configuration
Hooks can be declared as a simple handler or with full configuration:
<Tabs>
<TabItem label="Simple">
```typescript
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved");
}
}
```
</TabItem>
<TabItem label="Full Config">
```typescript
hooks: {
"content:afterSave": {
priority: 100,
timeout: 5000,
dependencies: ["audit-log"],
errorPolicy: "continue",
handler: async (event, ctx) => {
ctx.log.info("Content saved");
}
}
}
```
</TabItem>
</Tabs>
### Configuration Options
| Option | Type | Default | Description |
| -------------- | ----------------------- | --------- | ------------------------------------------ |
| `priority` | `number` | `100` | Execution order. Lower numbers run first. |
| `timeout` | `number` | `5000` | Maximum execution time in milliseconds. |
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run before this hook. |
| `errorPolicy` | `"abort" \| "continue"` | `"abort"` | Whether to stop the pipeline on error. |
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider. Used for `email:deliver` and `comment:moderate`. |
| `handler` | `function` | — | The hook handler function. Required. |
<Aside type="tip">
Use `errorPolicy: "continue"` for non-critical hooks like notifications. Use `"abort"` (the
default) when the hook's result is essential.
</Aside>
## Lifecycle Hooks
Lifecycle hooks run during plugin installation, activation, and deactivation.
### `plugin:install`
Runs once when the plugin is first added to a site.
```typescript
"plugin:install": async (_event, ctx) => {
ctx.log.info("Installing plugin...");
// Seed default data
await ctx.kv.set("settings:enabled", true);
await ctx.storage.items!.put("default", { name: "Default Item" });
}
```
**Event:** `{}`
**Returns:** `Promise<void>`
### `plugin:activate`
Runs when the plugin is enabled (after install or when re-enabled).
```typescript
"plugin:activate": async (_event, ctx) => {
ctx.log.info("Plugin activated");
}
```
**Event:** `{}`
**Returns:** `Promise<void>`
### `plugin:deactivate`
Runs when the plugin is disabled (but not removed).
```typescript
"plugin:deactivate": async (_event, ctx) => {
ctx.log.info("Plugin deactivated");
// Release resources, pause background work
}
```
**Event:** `{}`
**Returns:** `Promise<void>`
### `plugin:uninstall`
Runs when the plugin is removed from a site.
```typescript
"plugin:uninstall": async (event, ctx) => {
ctx.log.info("Uninstalling plugin...");
if (event.deleteData) {
// User opted to delete plugin data
const result = await ctx.storage.items!.query({ limit: 1000 });
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
}
}
```
**Event:** `{ deleteData: boolean }`
**Returns:** `Promise<void>`
<Aside type="caution">
Be conservative in `plugin:uninstall`. Default to preserving data—users may reinstall. Only delete
when `event.deleteData` is `true`.
</Aside>
## Content Hooks
Content hooks run during create, update, and delete operations.
### `content:beforeSave`
Runs before content is saved. Return modified content or `void` to keep it unchanged. Throw to cancel the save.
```typescript
"content:beforeSave": async (event, ctx) => {
const { content, collection, isNew } = event;
// Validate
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:**
```typescript
{
content: Record<string, unknown>; // Content data being saved
collection: string; // Collection name
isNew: boolean; // True if creating, false if updating
}
```
**Returns:** `Promise<Record<string, unknown> | void>`
### `content:afterSave`
Runs after content is successfully saved. Use for side effects like notifications, logging, or syncing to external systems.
```typescript
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
// Trigger external sync
if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
method: "POST",
body: JSON.stringify({ event: "content:save", id: content.id })
});
}
}
```
**Event:**
```typescript
{
content: Record<string, unknown>; // Saved content (includes id, timestamps)
collection: string;
isNew: boolean;
}
```
**Returns:** `Promise<void>`
### `content:beforeDelete`
Runs before content is deleted. Return `false` to cancel the deletion, `true` or `void` to allow it.
```typescript
"content:beforeDelete": async (event, ctx) => {
const { id, collection } = event;
// Prevent deletion of protected content
if (collection === "pages" && id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
}
```
**Event:**
```typescript
{
id: string; // Content ID being deleted
collection: string;
}
```
**Returns:** `Promise<boolean | void>`
### `content:afterDelete`
Runs after content is successfully deleted.
```typescript
"content:afterDelete": async (event, ctx) => {
const { id, collection } = event;
ctx.log.info(`Deleted ${collection}/${id}`);
// Clean up related plugin data
await ctx.storage.cache!.delete(`${collection}:${id}`);
}
```
**Event:**
```typescript
{
id: string;
collection: string;
}
```
**Returns:** `Promise<void>`
### `content:afterPublish`
Runs after content is published (promoted from draft to live). Use for side effects like cache invalidation, notifications, or syncing to external systems.
Requires `content:read` capability.
```typescript
"content:afterPublish": async (event, ctx) => {
const { content, collection } = event;
ctx.log.info(`Published ${collection}/${content.id}`);
// Notify external system
if (ctx.http) {
await ctx.http.fetch("https://api.example.com/webhook", {
method: "POST",
body: JSON.stringify({ event: "content:publish", id: content.id })
});
}
}
```
**Event:**
```typescript
{
content: Record<string, unknown>; // Published content (includes id, timestamps)
collection: string;
}
```
**Returns:** `Promise<void>`
### `content:afterUnpublish`
Runs after content is unpublished (reverted from live to draft). Use for side effects like cache invalidation or notifying external systems.
Requires `content:read` capability.
```typescript
"content:afterUnpublish": async (event, ctx) => {
const { content, collection } = event;
ctx.log.info(`Unpublished ${collection}/${content.id}`);
}
```
**Event:**
```typescript
{
content: Record<string, unknown>; // Unpublished content
collection: string;
}
```
**Returns:** `Promise<void>`
## Media Hooks
Media hooks run during file uploads.
### `media:beforeUpload`
Runs before a file is uploaded. Return modified file info or `void` to keep it unchanged. Throw to cancel the upload.
```typescript
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
// Validate file type
if (!file.type.startsWith("image/")) {
throw new Error("Only images are allowed");
}
// Validate file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
throw new Error("File too large");
}
// Rename file
return {
...file,
name: `${Date.now()}-${file.name}`
};
}
```
**Event:**
```typescript
{
file: {
name: string; // Original filename
type: string; // MIME type
size: number; // Size in bytes
}
}
```
**Returns:** `Promise<{ name: string; type: string; size: number } | void>`
### `media:afterUpload`
Runs after a file is successfully uploaded.
```typescript
"media:afterUpload": async (event, ctx) => {
const { media } = event;
ctx.log.info(`Uploaded ${media.filename}`, {
id: media.id,
size: media.size,
mimeType: media.mimeType
});
}
```
**Event:**
```typescript
{
media: {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
}
}
```
**Returns:** `Promise<void>`
## Hook Execution Order
Hooks run in this order:
1. Hooks with lower `priority` values run first
2. For equal priorities, hooks run in plugin registration order
3. Hooks with `dependencies` wait for those plugins to complete
```typescript
// Plugin A
"content:afterSave": {
priority: 50, // Runs first
handler: async () => {}
}
// Plugin B
"content:afterSave": {
priority: 100, // Runs second (default priority)
handler: async () => {}
}
// Plugin C
"content:afterSave": {
priority: 200,
dependencies: ["plugin-a"], // Runs after A, even if priority was lower
handler: async () => {}
}
```
## Error Handling
When a hook throws or times out:
- **`errorPolicy: "abort"`** — The entire pipeline stops. The original operation may fail.
- **`errorPolicy: "continue"`** — The error is logged, and remaining hooks still run.
```typescript
"content:afterSave": {
timeout: 5000,
errorPolicy: "continue", // Don't fail the save if this hook fails
handler: async (event, ctx) => {
// External API call that might fail
await ctx.http!.fetch("https://unreliable-api.com/notify");
}
}
```
<Aside type="tip">
Use `errorPolicy: "continue"` for non-critical operations like analytics, notifications, or
external syncs. The content save succeeds even if the hook fails.
</Aside>
## Timeouts
Hooks have a default timeout of 5000ms (5 seconds). Increase it for operations that may take longer:
```typescript
"content:afterSave": {
timeout: 30000, // 30 seconds
handler: async (event, ctx) => {
// Long-running operation
}
}
```
<Aside type="caution">
In sandboxed mode on Cloudflare, resource limits are enforced at the isolate level. Long-running
hooks may be terminated regardless of the configured timeout.
</Aside>
## Public Page Hooks
Public page hooks let plugins contribute to the `<head>` and `<body>` of rendered pages. Templates opt in using the `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components from `emdash/ui`.
### `page:metadata`
Contributes typed metadata to `<head>` — meta tags, OpenGraph properties, canonical/alternate links, and JSON-LD structured data. Works in both native and sandboxed modes.
Core validates, deduplicates, and renders the contributions. Plugins return structured data, never raw HTML.
```typescript
"page:metadata": async (event, ctx) => {
if (event.page.kind !== "content") return null;
return {
kind: "jsonld",
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
graph: {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: event.page.pageTitle ?? event.page.title,
description: event.page.description,
},
};
}
```
**Event:**
```typescript
{
page: {
url: string;
path: string;
locale: string | null;
kind: "content" | "custom";
pageType: string;
title: string | null;
pageTitle?: string | null;
description: string | null;
canonical: string | null;
image: string | null;
content?: { collection: string; id: string; slug: string | null };
}
}
```
**Returns:** `PageMetadataContribution | PageMetadataContribution[] | null`
**Contribution types:**
| Kind | Renders | Dedupe key |
| ---------- | -------------------------------------------------- | ----------------------- |
| `meta` | `<meta name="..." content="...">` | `key` or `name` |
| `property` | `<meta property="..." content="...">` | `key` or `property` |
| `link` | `<link rel="canonical\|alternate" href="...">` | canonical: singleton; alternate: `key` or `hreflang` |
| `jsonld` | `<script type="application/ld+json">` | `id` (if present) |
First contribution wins for any dedupe key. Link hrefs must be HTTP or HTTPS.
### `page:fragments`
Contributes raw HTML, scripts, or markup to page insertion points. **Native plugins only** — sandboxed plugins cannot use this hook.
```typescript
"page:fragments": async (event, ctx) => {
return {
kind: "external-script",
placement: "head",
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
async: true,
};
}
```
**Returns:** `PageFragmentContribution | PageFragmentContribution[] | null`
Placements: `"head"`, `"body:start"`, `"body:end"`. Templates that omit a component for a placement silently ignore contributions targeting it.
<Aside type="caution">
`page:fragments` is native-only because its output runs as first-party code in the browser.
Worker Loader isolation does not extend to browser-executed scripts. Use `page:metadata` for
sandbox-safe contributions.
</Aside>
## Hooks Reference
| Hook | Trigger | Return | Exclusive |
| ----------------------- | ------------------------------ | ------------------------------ | --------- |
| `plugin:install` | First plugin installation | `void` | No |
| `plugin:activate` | Plugin enabled | `void` | No |
| `plugin:deactivate` | Plugin disabled | `void` | No |
| `plugin:uninstall` | Plugin removed | `void` | No |
| `content:beforeSave` | Before content save | Modified content or `void` | No |
| `content:afterSave` | After content save | `void` | No |
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow | No |
| `content:afterDelete` | After content delete | `void` | No |
| `content:afterPublish` | After content publish | `void` | No |
| `content:afterUnpublish`| After content unpublish | `void` | No |
| `media:beforeUpload` | Before file upload | Modified file info or `void` | No |
| `media:afterUpload` | After file upload | `void` | No |
| `cron` | Scheduled task fires | `void` | No |
| `email:beforeSend` | Before email delivery | Modified message, `false`, or `void` | No |
| `email:deliver` | Deliver email via transport | `void` | Yes |
| `email:afterSend` | After email delivery | `void` | No |
| `comment:beforeCreate` | Before comment stored | Modified event, `false`, or `void` | No |
| `comment:moderate` | Decide comment status | `{ status, reason? }` | Yes |
| `comment:afterCreate` | After comment stored | `void` | No |
| `comment:afterModerate` | Admin changes comment status | `void` | No |
| `page:metadata` | Page render | Contributions or `null` | No |
| `page:fragments` | Page render (native only) | Contributions or `null` | No |
See the [Hook Reference](/reference/hooks/) for complete event types and handler signatures.

View File

@@ -0,0 +1,140 @@
---
title: Installing Plugins
description: Install plugins from the EmDash Marketplace or add them from code.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash plugins can be installed in two ways: from the marketplace via the admin dashboard, or added directly in your Astro configuration. Marketplace plugins run in an isolated sandbox; config-based plugins run in-process.
## From the Marketplace
The admin dashboard includes a marketplace browser where you can search, install, and manage plugins.
### Prerequisites
To install marketplace plugins, your site needs:
1. **Sandbox runner configured** — Marketplace plugins run in isolated V8 workers, which requires the sandbox runtime:
```typescript title="astro.config.mjs"
import { emdash } from "emdash/astro";
export default defineConfig({
integrations: [
emdash({
marketplace: "https://marketplace.emdashcms.com",
sandboxRunner: true,
}),
],
});
```
2. **Admin access** — Only administrators can install or remove plugins.
### Browse and Install
<Steps>
1. Open the admin panel and navigate to **Plugins > Marketplace**
2. Browse or search for a plugin
3. Click the plugin card to see its detail page — README, screenshots, capabilities, and security audit results
4. Click **Install**
5. Review the capability consent dialog — this shows what the plugin will be able to access
6. Confirm the installation
</Steps>
The plugin will be downloaded, stored in your site's R2 bucket, and loaded into the sandbox runner. It's active immediately.
### Capability Consent
Before installation, you'll see a dialog listing what the plugin needs access to:
| Capability | What it means |
| ---------- | ------------- |
| `content:read` | Read your content |
| `content:write` | Create, update, and delete content |
| `media:read` | Access your media library |
| `media:write` | Upload and manage media |
| `network:request` | Make network requests to specific hosts |
<Aside type="caution">
Only install plugins from authors you trust. The capability system limits what a sandboxed plugin can access, but a plugin with `content:write` can modify any content on your site.
</Aside>
### Security Audit
Every plugin version in the marketplace has been through an automated security audit. The audit verdict appears on the plugin card:
- **Pass** — No issues found
- **Warn** — Minor concerns flagged (review the findings)
- **Fail** — Significant security issues detected
You can view the full audit report on the plugin's detail page, including individual findings and their severity.
### Updates
When a newer version of an installed plugin is available:
1. Go to **Plugins** in the admin panel
2. Marketplace plugins show an **Update available** badge
3. Click **Update** to see the changelog and any capability changes
4. If the new version requires additional capabilities, you'll see a diff and need to approve
5. Confirm to update
<Aside type="note">
Updates that add new capabilities require explicit approval. If a plugin that previously only read content now wants to make network requests, you'll see the new capability highlighted before confirming.
</Aside>
### Uninstalling
1. Go to **Plugins** in the admin panel
2. Click the marketplace plugin you want to remove
3. Click **Uninstall**
4. Choose whether to keep or delete the plugin's stored data
5. Confirm
The plugin's sandbox code is removed from your R2 bucket and it stops running immediately.
## From Configuration
For native plugins (your own code, or packages you install via npm), add them directly to your Astro config:
```typescript title="astro.config.mjs"
import { defineConfig } from "astro/config";
import { emdash } from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";
export default defineConfig({
integrations: [
emdash({
plugins: [
seoPlugin({ generateSitemap: true }),
],
}),
],
});
```
Native plugins:
- Run in-process (not sandboxed)
- Have full access to Node.js APIs
- Are loaded at build time and on every server start
- Cannot be installed or removed from the admin UI
<Aside type="tip">
Use native plugins only when you need features that require build-time integration: React admin pages, Portable Text rendering components, or page fragment injection. For everything else, prefer sandboxed plugins -- they can be installed, updated, and removed without code changes or redeployments.
</Aside>
## Marketplace vs. Config: When to Use Which
| | Marketplace (sandboxed) | Config (native) |
| --- | --- | --- |
| **Install method** | One-click in admin UI | Code change + `npm install` + deploy |
| **Execution** | Isolated V8 isolate | In-process |
| **Capabilities** | Enforced at runtime | Advisory only |
| **Node.js APIs** | Not available | Full access |
| **React admin pages** | No (Block Kit instead) | Yes |
| **PT rendering components** | No | Yes |
| **Updates** | One-click in admin | Version bump + deploy |
| **Best for** | Most plugins | Plugins needing build-time integration |

View File

@@ -0,0 +1,224 @@
---
title: Plugin System Overview
description: Extend EmDash with plugins that add hooks, storage, settings, and admin UI.
---
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
EmDash's plugin system lets you extend the CMS without modifying core code. Plugins can hook into content lifecycle events, store their own data, expose settings to administrators, and add custom UI to the admin panel.
## Design Philosophy
EmDash plugins come in two flavors: **sandboxed** and **native**. Sandboxed plugins run in isolated V8 workers and can be installed from the marketplace with one click. Native plugins run in-process and are configured in code.
**Prefer sandboxed plugins.** They can be installed, updated, and removed from the admin UI without touching code or redeploying. Only use native plugins when you need features that require build-time integration (React admin pages, Portable Text rendering components, or page fragment injection).
**Key principles:**
- **Sandbox-first** — Design for the sandbox; use native mode only when you need to
- **Declarative** — Hooks, storage, and routes are declared at definition time, not registered dynamically
- **Type-safe** — Full TypeScript support with typed context objects
- **Capability-based** — Plugins declare what they need; the sandbox enforces it
<Aside type="note">
EmDash plugins are not Astro integrations. They're passed to the EmDash integration in your
Astro config. A plugin that needs both can ship as an Astro integration that also registers
EmDash hooks.
</Aside>
## What Plugins Can Do
<CardGrid>
<Card title="Hook into events" icon="rocket">
Run code before or after content saves, media uploads, and plugin lifecycle events.
</Card>
<Card title="Store data" icon="document">
Persist plugin-specific data in indexed collections without writing database migrations.
</Card>
<Card title="Expose settings" icon="setting">
Declare a settings schema and get an auto-generated admin UI for configuration.
</Card>
<Card title="Add admin pages" icon="laptop">
Create custom admin pages and dashboard widgets with React components.
</Card>
<Card title="Create API routes" icon="external">
Expose endpoints for your plugin's admin UI or external integrations.
</Card>
<Card title="Make HTTP requests" icon="external">
Call external APIs with declared host restrictions for security.
</Card>
</CardGrid>
## Plugin Architecture
Every plugin is created with `definePlugin()`:
```typescript
import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
// What APIs the plugin needs access to
capabilities: ["content:read", "network:request"],
// Hosts the plugin can make HTTP requests to
allowedHosts: ["api.example.com"],
// Persistent storage collections
storage: {
entries: {
indexes: ["userId", "createdAt"],
},
},
// Event handlers
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
// REST API endpoints
routes: {
status: {
handler: async (ctx) => ({ ok: true }),
},
},
// Admin UI configuration
admin: {
settingsSchema: {
apiKey: { type: "secret", label: "API Key" },
},
pages: [{ path: "/dashboard", label: "Dashboard" }],
widgets: [{ id: "status", size: "half" }],
},
});
```
## Plugin Context
Every hook and route handler receives a `PluginContext` object with access to:
| Property | Description | Availability |
| ------------- | ---------------------------------------------------- | -------------------------------------- |
| `ctx.storage` | Plugin's document collections | Always (if declared) |
| `ctx.kv` | Key-value store for settings and state | Always |
| `ctx.content` | Read/write site content | With `content:read` or `content:write` |
| `ctx.media` | Read/write media files | With `media:read` or `media:write` |
| `ctx.http` | HTTP client for external requests | With `network:request` |
| `ctx.log` | Structured logger (debug, info, warn, error) | Always |
| `ctx.plugin` | Plugin metadata (id, version) | Always |
| `ctx.site` | Site info: `name`, `url`, `locale` | Always |
| `ctx.url()` | Generate absolute URLs from paths | Always |
| `ctx.users` | Read user info: `get()`, `getByEmail()`, `list()` | With `users:read` |
| `ctx.cron` | Schedule tasks: `schedule()`, `cancel()`, `list()` | Always |
| `ctx.email` | Send email: `send()` | With `email:send` + provider configured |
The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability.
## Capabilities
Capabilities determine what APIs are available in the plugin context. Capability
names follow the formula `<resource>[.<sub-resource>]:<verb>[:<qualifier>]` —
resource first, verb second.
| Capability | Grants Access To |
| ----------------------------------- | ---------------------------------------------------------------------- |
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) |
| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) |
| `users:read` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
| `email:send` | `ctx.email.send()` (requires a provider plugin) |
| `hooks.email-transport:register` | Register `email:deliver` exclusive hook (transport provider) |
| `hooks.email-events:register` | Register `email:beforeSend` / `email:afterSend` hooks |
| `hooks.page-fragments:register` | Register `page:fragments` hook (inject scripts/styles into pages) |
<Aside type="tip">
`content:write` implies `content:read`. Same for media. Declare only what you
need.
</Aside>
<Aside type="caution" title="Renamed in this minor">
The capability names were unified in this minor. Old names are still accepted
with `@deprecated` warnings and will be removed in the next minor:
| Old | New |
| -------------------- | ---------------------------------- |
| `read:content` | `content:read` |
| `write:content` | `content:write` |
| `read:media` | `media:read` |
| `write:media` | `media:write` |
| `read:users` | `users:read` |
| `network:fetch` | `network:request` |
| `network:fetch:any` | `network:request:unrestricted` |
| `email:provide` | `hooks.email-transport:register` |
| `email:intercept` | `hooks.email-events:register` |
| `page:inject` | `hooks.page-fragments:register` |
`emdash plugin bundle` and `emdash plugin validate` warn for each
deprecated name. `emdash plugin publish` refuses manifests that still use
deprecated names — re-bundle after renaming.
</Aside>
## Registration
Register plugins in your Astro configuration:
```typescript title="astro.config.mjs"
import { defineConfig } from "astro/config";
import { emdash } from "emdash/astro";
import seoPlugin from "@emdash-cms/plugin-seo";
import auditLogPlugin from "@emdash-cms/plugin-audit-log";
export default defineConfig({
integrations: [
emdash({
plugins: [seoPlugin({ generateSitemap: true }), auditLogPlugin({ retentionDays: 90 })],
}),
],
});
```
Plugins are resolved at build time. Order matters for hooks with the same priority—earlier plugins in the array run first.
## Execution Modes
EmDash supports two plugin execution modes:
| Mode | Description | Platform |
| ------------- | ------------------------------------------ | --------------- |
| **Sandboxed** | Isolated V8 workers with enforced limits | Cloudflare only |
| **Native** | In-process with full access | Any |
In sandboxed mode, capabilities are enforced at the runtime level -- plugins can only access what they declare. In native mode, capabilities are advisory and plugins have full process access.
<Aside type="note">
Sandboxed execution requires Cloudflare Workers with Dynamic Worker Loader. Other platforms use
native mode only. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for a full comparison.
</Aside>
## Next Steps
<CardGrid>
<Card title="Create a Plugin" icon="add-document">
[Build your first plugin](/plugins/creating-plugins/) with storage, hooks, and admin UI.
</Card>
<Card title="Available Hooks" icon="rocket">
[Browse all hooks](/plugins/hooks/) for content, media, and plugin lifecycle.
</Card>
<Card title="Plugin Storage" icon="document">
[Learn about storage](/plugins/storage/) and how to query plugin data.
</Card>
<Card title="Admin UI" icon="laptop">
[Add admin pages](/plugins/admin-ui/) and dashboard widgets.
</Card>
<Card title="Native vs. Sandboxed" icon="warning">
[Compare execution modes](/plugins/sandbox/) and choose the right one for your plugin.
</Card>
</CardGrid>

View File

@@ -0,0 +1,200 @@
---
title: Publishing Plugins
description: Bundle and publish your EmDash plugin to the marketplace.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Once you've built a plugin, you can publish it to the EmDash Marketplace so other sites can install it from the admin dashboard.
## Prerequisites
Before publishing, make sure your plugin:
- Has a valid `package.json` with the `"."` export pointing to your plugin entry
- Uses `definePlugin()` with a unique `id` and valid semver `version`
- Declares its `capabilities` (what APIs it needs access to)
## Bundle Format
Published plugins are distributed as `.tar.gz` tarballs containing:
| File | Required | Description |
| ---------------- | -------- | -------------------------------------- |
| `manifest.json` | Yes | Plugin 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) |
The `manifest.json` is generated automatically from your `definePlugin()` call. It contains the plugin ID, version, capabilities, hook names, route names, and admin configuration — but no executable code.
## Building a Bundle
The `emdash plugin bundle` command produces a tarball from your plugin source:
```bash
cd packages/plugins/my-plugin
emdash plugin bundle
```
This will:
<Steps>
1. Read your `package.json` to find entrypoints
2. Build the main entry with tsdown to extract the manifest
3. Bundle `backend.js` (minified, tree-shaken, self-contained)
4. Bundle `admin.js` if an `"./admin"` export exists
5. Collect assets (README, icon, screenshots)
6. Validate the bundle (size limits, no Node.js built-ins in backend)
7. Write `{id}-{version}.tar.gz` to `dist/`
</Steps>
### Entrypoint Resolution
The bundle command finds your code through `package.json` exports:
```json title="package.json"
{
"exports": {
".": { "import": "./dist/index.mjs" },
"./sandbox": { "import": "./dist/sandbox-entry.mjs" },
"./admin": { "import": "./dist/admin.mjs" }
}
}
```
| Export | Purpose | Built as |
| ------------ | ------- | -------- |
| `"."` | Main entry — used to extract the manifest | Externals: `emdash`, `@emdash-cms/*` |
| `"./sandbox"` | Backend code that runs in 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` as a fallback.
<Aside type="note">
The bundle command maps dist paths back to source automatically. If your `"."` export points to `./dist/index.mjs`, it will find and build `src/index.ts`.
</Aside>
### Options
```bash
emdash plugin bundle [--dir <path>] [--outDir <path>]
```
| Flag | Default | Description |
| ---- | ------- | ----------- |
| `--dir` | Current directory | Plugin source directory |
| `--outDir`, `-o` | `dist` | Output directory for the tarball |
### Validation
The bundle command checks:
- **Size limit** — Total bundle must be under 5MB
- **No Node.js built-ins** — `backend.js` cannot import `fs`, `path`, `child_process`, etc. (sandbox code runs in a V8 isolate, not Node.js)
- **Icon dimensions** — `icon.png` should be 256x256 (warns if wrong, still includes it)
- **Screenshot limits** — Max 5 screenshots, max 1920x1080
<Aside type="tip">
If your backend code imports a Node.js built-in, the bundle will fail validation. Replace Node.js APIs with Web APIs or move the logic to a native plugin instead.
</Aside>
## Publishing
The `emdash plugin publish` command uploads your tarball to the marketplace:
```bash
emdash plugin publish
```
This will find the most recent `.tar.gz` in your `dist/` directory and upload it. You can also specify the tarball explicitly or build before publishing:
```bash
# Explicit tarball path
emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz
# Build first, then publish
emdash plugin publish --build
```
### Authentication
The first time you publish, the CLI authenticates you via GitHub:
<Steps>
1. The CLI opens your browser to GitHub's device authorization page
2. You enter the code displayed in your terminal
3. GitHub issues an access token
4. The CLI exchanges it for a marketplace JWT (stored in `~/.config/emdash/auth.json`)
</Steps>
The token lasts 30 days. After it expires, you'll be prompted to re-authenticate on the next publish.
You can also manage authentication separately:
```bash
# Log in without publishing
emdash plugin login
# Log out (clear stored token)
emdash plugin logout
```
### First-Time Registration
If your plugin ID doesn't exist in the marketplace yet, `emdash plugin publish` registers it automatically before uploading the first version.
### Version Requirements
Each published version must have a higher semver than the last. You cannot overwrite or republish an existing version.
### Security Audit
Every published version goes through an automated security audit. The marketplace scans your `backend.js` and `admin.js` for:
- Data exfiltration patterns
- Credential harvesting via settings
- Obfuscated code
- Resource abuse (crypto mining, etc.)
- Suspicious network activity
The audit produces a verdict of **pass**, **warn**, or **fail**, which is displayed on the plugin's marketplace listing. Depending on the marketplace's enforcement level, a **fail** verdict may block publication entirely.
### Options
```bash
emdash plugin publish [--tarball <path>] [--build] [--dir <path>] [--registry <url>]
```
| Flag | Default | Description |
| ---- | ------- | ----------- |
| `--tarball` | Latest `.tar.gz` in `dist/` | Explicit tarball path |
| `--build` | `false` | Run `emdash plugin bundle` before publishing |
| `--dir` | Current directory | Plugin directory (used with `--build`) |
| `--registry` | `https://marketplace.emdashcms.com` | Marketplace URL |
## Complete Workflow
Here's the typical publish cycle:
```bash
# 1. Make your changes
# 2. Bump the version in definePlugin() and package.json
# 3. Bundle and publish in one step
emdash plugin publish --build
```
Or if you prefer to inspect the bundle first:
```bash
# Build the tarball
emdash plugin bundle
# Check the output
tar tzf dist/my-plugin-1.1.0.tar.gz
# Publish
emdash plugin publish
```

View File

@@ -0,0 +1,335 @@
---
title: Native vs. Sandboxed Plugins
description: Understand the two plugin execution modes, what each supports, and why you should prefer sandboxed plugins.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
EmDash plugins run in one of two modes: **sandboxed** or **native**. Both use the same `definePlugin()` API and the same hooks, but they differ in how they're installed, what enforcement they get, and what extra features are available.
**Prefer sandboxed plugins.** Sandboxed plugins can be installed from the marketplace with one click -- no `npm install`, no rebuild, no redeploy. Native plugins require a code change, a dependency install, and a full rebuild. If your plugin can work within the sandbox, it should.
<Aside type="tip">
The plugin code is identical in both modes. Write your plugin using the standard format, test it locally as a native plugin for fast iteration, and publish it to the marketplace as a sandboxed plugin.
</Aside>
## Quick Comparison
| | Sandboxed | Native |
|---|---|---|
| **Install method** | One-click from admin UI | Code change + `npm install` + deploy |
| **Runs in** | Isolated V8 isolate | Same process as your Astro site |
| **Capabilities** | Enforced by RPC bridge | Advisory only (not enforced) |
| **Resource limits** | CPU, memory, subrequests, wall-time | None |
| **Network access** | Via `ctx.http` with host allowlist | Unrestricted |
| **Data isolation** | Full -- scoped storage and KV | None -- shares the process |
| **Platform** | Cloudflare Workers | All platforms |
| **Admin UI** | Block Kit (JSON-based) | React components or Block Kit |
| **PT block types** | Not available | Astro components via `componentsEntry` |
| **Page fragments** | Not available | Available with `hooks.page-fragments:register` capability |
## What Both Modes Support
The core plugin API is the same regardless of execution mode. Both sandboxed and native plugins can use:
| Feature | Details |
|---|---|
| **All 22 hooks** | Content, media, email, comments, cron, page metadata, lifecycle |
| **API routes** | REST endpoints at `/_emdash/api/plugins/<id>/<route>` |
| **Plugin storage** | Document collections with indexes and queries |
| **KV store** | Key-value storage for settings and state |
| **Content CRUD** | Read, create, update, delete site content (with capability) |
| **Media CRUD** | Read, upload, delete media files (with capability) |
| **HTTP fetch** | External API calls (with capability and host allowlist) |
| **User access** | Read user info (with capability) |
| **Email** | Send email (with capability and provider configured) |
| **Cron scheduling** | Schedule recurring tasks |
| **Page metadata** | Contribute meta tags, OpenGraph, JSON-LD to `<head>` |
| **Settings schema** | Auto-generated admin settings UI |
| **Admin pages & widgets** | Via Block Kit |
## What Only Native Plugins Can Do
Three features require build-time integration that sandboxed plugins can't provide:
### Custom React Admin Pages
Native plugins can ship React components that render full admin pages and dashboard widgets. Sandboxed plugins use [Block Kit](/plugins/block-kit/) instead -- a JSON-based UI that the admin renders on the plugin's behalf.
<Tabs>
<TabItem label="Native (React)">
```typescript title="src/admin.tsx"
export function SettingsPage({ api }) {
const [config, setConfig] = useState(null);
useEffect(() => {
api.get("settings").then(setConfig);
}, []);
return (
<form onSubmit={() => api.post("settings/save", config)}>
<input value={config?.apiKey} onChange={...} />
</form>
);
}
```
</TabItem>
<TabItem label="Sandboxed (Block Kit)">
```typescript title="src/sandbox-entry.ts"
routes: {
admin: {
handler: async (routeCtx, ctx) => {
const apiKey = await ctx.kv.get("settings:apiKey");
return {
blocks: [
{ type: "header", text: "Settings" },
{
type: "input",
element: {
type: "text_input",
action_id: "apiKey",
initial_value: apiKey ?? "",
},
label: "API Key",
},
{
type: "actions",
elements: [
{ type: "button", text: "Save", action_id: "save" },
],
},
],
};
},
},
}
```
</TabItem>
</Tabs>
### Portable Text Block Types
Plugins can add custom block types to the Portable Text editor (YouTube embeds, code snippets, etc.). The editing UI uses Block Kit fields, which works in both modes. But rendering those blocks on the public site requires Astro components loaded at build time from npm -- so only native plugins can provide a `componentsEntry`.
Sandboxed plugins can still declare PT block types with editing fields. The site author just needs to provide their own rendering components or use a companion native package for rendering.
### Page Fragment Injection
The `page:fragments` hook injects raw HTML, scripts, or stylesheets into public pages. Because these fragments execute as first-party code in the visitor's browser -- outside any sandbox boundary -- this hook is restricted to native plugins. Sandboxed plugins can use `page:metadata` to contribute structured data (meta tags, OpenGraph, JSON-LD) instead.
## How Sandboxing Works
Sandboxed plugins run in isolated V8 isolates provided by Cloudflare's [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). The plugin code never shares memory, globals, or bindings with your Astro site.
### Architecture
```
┌─────────────────────┐ RPC ┌──────────────────────┐
│ Plugin Isolate │ <----------> │ PluginBridge │
│ (V8 Worker Loader) │ (binding) │ (WorkerEntrypoint) │
│ │ │ │
│ ctx.kv.get(k) │─────────────>│ kvGet(k) │
│ ctx.content.list() │─────────────>│ contentList() │
│ ctx.http.fetch(u) │─────────────>│ httpFetch(u) │
└─────────────────────┘ └──────────────────────┘
v
┌──────────────┐
│ D1 / R2 │
└──────────────┘
```
Every `ctx` method is a proxy to the bridge. The bridge validates capabilities, scopes storage, and enforces host allowlists before touching any real resources.
### What the Sandbox Enforces
<Steps>
1. **Capability enforcement**
If a plugin declares `capabilities: ["content:read"]`, it can call `ctx.content.get()` and `ctx.content.list()` -- nothing else. Attempting `ctx.content.create()` throws a permission error. The plugin cannot bypass this because it has no direct database access.
2. **Resource limits**
Every hook or route invocation runs with hard limits:
| Resource | Default | Enforced by |
|---|---|---|
| CPU time | 50ms | Worker Loader (V8 isolate abort) |
| Subrequests | 10 per invocation | Worker Loader (V8 isolate abort) |
| Wall-clock time | 30 seconds | EmDash runner (`Promise.race`) |
| Memory | ~128MB | V8 platform ceiling |
Exceeding CPU or subrequest limits aborts the isolate. Exceeding wall-time rejects the invocation promise.
3. **Network isolation**
Direct `fetch()` is blocked at the V8 level (`globalOutbound: null`). Plugins must use `ctx.http.fetch()`, which proxies through the bridge and validates the target host against the plugin's `allowedHosts` list.
4. **Storage scoping**
All storage and KV operations are scoped to the plugin's ID. A plugin cannot read another plugin's data, and attempting to access undeclared storage collections throws an error.
5. **No environment access**
Sandboxed plugins have no access to environment variables, the filesystem, or any host bindings. The V8 isolate context is clean.
</Steps>
### Wrangler Configuration
Sandboxing requires Dynamic Worker Loader. Add to your `wrangler.jsonc`:
```jsonc
{
"worker_loaders": [{ "binding": "LOADER" }]
}
```
## How Native Plugins Work
Native plugins run in the same process as your Astro site. They're loaded from npm packages or local files and configured in `astro.config.mjs`:
```typescript title="astro.config.mjs"
import myPlugin from "@emdash-cms/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()],
}),
],
});
```
In native mode:
- **Capabilities are advisory.** A plugin declaring `["content:read"]` can still access anything in the process. The `capabilities` field documents what the plugin *intends* to use, but nothing prevents it from importing modules, calling `fetch()` directly, or reading environment variables.
- **No resource limits.** CPU, memory, and network usage are unbounded.
- **Full process access.** The plugin shares the runtime with your Astro site.
<Aside type="caution">
Only install native plugins from sources you trust -- npm packages you've reviewed, or code you've written yourself. A native plugin has the same access as your application code.
</Aside>
### Native Plugin Formats
Native plugins can use either of two formats:
<Tabs>
<TabItem label="Standard format (recommended)">
Standard format uses a simple `{ hooks, routes }` structure. The same code can run in both native and sandboxed mode. Metadata (id, version, capabilities) comes from the plugin descriptor, not from `definePlugin()`.
```typescript title="src/sandbox-entry.ts"
import { definePlugin } from "emdash";
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
routes: {
status: {
handler: async (routeCtx, ctx) => {
return { ok: true };
},
},
},
});
```
</TabItem>
<TabItem label="Native format">
Native format includes id, version, capabilities, and admin configuration directly in `definePlugin()`. This format can only run as a native plugin -- it cannot be sandboxed or published to the marketplace.
```typescript title="src/index.ts"
import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
capabilities: ["content:read"],
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
routes: {
status: {
handler: async (ctx) => {
return { ok: true };
},
},
},
admin: {
entry: "@my-org/my-plugin/admin",
settingsSchema: { /* ... */ },
},
});
```
</TabItem>
</Tabs>
Use standard format unless you need native-only features (React admin pages, PT components, page fragments).
## Node.js Deployments
<Aside type="danger" title="No sandbox on Node.js">
Node.js does not support plugin sandboxing. All plugins run as native plugins regardless of configuration. There is no V8 isolate boundary, no resource limits, and no capability enforcement at the runtime level.
</Aside>
When deploying to Node.js (or any non-Cloudflare platform):
- The `NoopSandboxRunner` is used -- `isAvailable()` returns `false`
- Attempting to load sandboxed plugins throws `SandboxNotAvailableError`
- All plugins must be registered as native plugins in the `plugins` array
- Capability declarations are purely informational
### Security Comparison by Platform
| Threat | Cloudflare (sandboxed) | Node.js (native only) |
|---|---|---|
| Plugin reads unauthorized data | Blocked by bridge capability checks | Not prevented -- full DB access |
| Unauthorized network calls | Blocked (`globalOutbound: null` + host allowlist) | Not prevented -- direct `fetch()` |
| CPU exhaustion | Isolate aborted by Worker Loader | Not prevented -- blocks the event loop |
| Memory exhaustion | Isolate terminated | Not prevented -- can crash the process |
| Env variable access | No access (isolated V8 context) | Not prevented -- shares `process.env` |
| Filesystem access | No filesystem in Workers | Not prevented -- full `fs` access |
For Node.js deployments, review native plugin source code before installing and use capability declarations as a review checklist.
## Choosing the Right Mode
**Start with sandboxed.** If your plugin uses hooks, routes, storage, and the standard context API, it works in the sandbox. Most plugins fit this model.
**Go native when you need to:**
- Ship custom React admin pages (not Block Kit)
- Provide Astro components for rendering PT block types on the public site
- Inject scripts or HTML into public pages via `page:fragments`
- Access Node.js APIs or environment variables directly
- Use npm dependencies that aren't bundleable into a single ES module
If you're unsure, build with the standard format. You can always add native-only features later, but going the other direction -- native to sandboxed -- is harder because native-only features don't have sandbox equivalents.
## Same Code, Different Guarantees
A plugin written in standard format runs identically in both modes. What changes is the enforcement layer:
```typescript
// This plugin works as both native and sandboxed
export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx) => {
// Native mode: ctx.http is always present (capabilities not enforced)
// Sandboxed mode: ctx.http is present because the descriptor declares "network:request"
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
});
```
Develop locally as a native plugin for faster iteration. Publish to the marketplace as a sandboxed plugin for production. No code changes required.

View File

@@ -0,0 +1,357 @@
---
title: Plugin Settings
description: Configure plugins with settings schemas and the KV store.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Plugins need configuration—API keys, feature flags, display preferences. EmDash provides two mechanisms: a **settings schema** for admin-configurable options and a **KV store** for programmatic access.
## Settings Schema
Declare a settings schema in `admin.settingsSchema` to auto-generate an admin UI:
```typescript
import { definePlugin } from "emdash";
export default definePlugin({
id: "seo",
version: "1.0.0",
admin: {
settingsSchema: {
siteTitle: {
type: "string",
label: "Site Title",
description: "Used in title tags and meta",
default: "",
},
maxTitleLength: {
type: "number",
label: "Max Title Length",
description: "Characters before truncation",
default: 60,
min: 30,
max: 100,
},
generateSitemap: {
type: "boolean",
label: "Generate Sitemap",
description: "Automatically generate sitemap.xml",
default: true,
},
defaultRobots: {
type: "select",
label: "Default Robots",
options: [
{ value: "index,follow", label: "Index & Follow" },
{ value: "noindex,follow", label: "No Index, Follow" },
{ value: "noindex,nofollow", label: "No Index, No Follow" },
],
default: "index,follow",
},
apiKey: {
type: "secret",
label: "API Key",
description: "Encrypted at rest",
},
},
},
});
```
EmDash generates a settings form in the plugin's admin section. Users edit settings without touching code.
## Field Types
### String
Text input for single-line or multiline strings.
```typescript
siteTitle: {
type: "string",
label: "Site Title",
description: "Optional help text",
default: "My Site",
multiline: false // Set true for textarea
}
```
### Number
Numeric input with optional min/max constraints.
```typescript
maxItems: {
type: "number",
label: "Maximum Items",
default: 100,
min: 1,
max: 1000
}
```
### Boolean
Toggle switch for true/false values.
```typescript
enabled: {
type: "boolean",
label: "Enabled",
description: "Turn this feature on or off",
default: true
}
```
### Select
Dropdown for predefined options.
```typescript
theme: {
type: "select",
label: "Theme",
options: [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
{ value: "auto", label: "System" }
],
default: "auto"
}
```
### Secret
Encrypted field for sensitive values like API keys. Never sent to the client after saving.
```typescript
apiKey: {
type: "secret",
label: "API Key",
description: "Stored encrypted"
}
```
<Aside type="caution">
Secret fields are encrypted at rest using the site's encryption key. They're never exposed in the
admin UI after the initial save—only a masked placeholder is shown.
</Aside>
### Url
Single-line text input with URL validation.
```typescript
website: {
type: "url",
label: "Website URL",
description: "Enter your website address",
default: "https://",
}
```
### Email
Text input with email format validation.
```typescript
contactEmail: {
type: "email",
label: "Contact Email",
description: "Support contact address",
default: "",
}
```
### Accessing Settings
Read settings in hooks and routes via `ctx.kv`:
```typescript
"content:beforeSave": async (event, ctx) => {
// Read a setting
const maxLength = await ctx.kv.get<number>("settings:maxTitleLength");
const apiKey = await ctx.kv.get<string>("settings:apiKey");
// Use defaults if not set
const limit = maxLength ?? 60;
ctx.log.info("Using max length", { limit });
return event.content;
}
```
Settings are stored with the `settings:` prefix by convention. This distinguishes user-configurable values from internal plugin state.
## KV Store API
The KV store (`ctx.kv`) is a general-purpose key-value store for plugin data:
```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 }>>;
}
```
### Reading Values
```typescript
// Get a single value
const enabled = await ctx.kv.get<boolean>("settings:enabled");
// Get with type
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");
```
### Writing Values
```typescript
// Set a value
await ctx.kv.set("settings:lastSync", new Date().toISOString());
// Set complex values
await ctx.kv.set("state:cache", {
data: items,
expiry: Date.now() + 3600000,
});
```
### Listing Values
```typescript
// List all settings
const settings = await ctx.kv.list("settings:");
// Returns: [{ key: "settings:enabled", value: true }, ...]
// List all plugin keys
const all = await ctx.kv.list();
```
### Deleting Values
```typescript
const deleted = await ctx.kv.delete("state:tempData");
// Returns true if key existed
```
## Key Naming Conventions
Use prefixes to organize KV data:
| Prefix | Purpose | Example |
| ----------- | ----------------------------- | ----------------- |
| `settings:` | User-configurable preferences | `settings:apiKey` |
| `state:` | Internal plugin state | `state:lastSync` |
| `cache:` | Cached data | `cache:results` |
```typescript
// Good: clear prefixes
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", timestamp);
await ctx.kv.set("cache:feed", feedData);
// Avoid: no prefix, unclear purpose
await ctx.kv.set("url", url);
```
<Aside type="tip">
The `settings:` prefix is a convention for values shown in the auto-generated settings UI. Other
prefixes are for plugin-internal use.
</Aside>
## Settings vs Storage vs KV
Choose the right storage mechanism:
| Use Case | Mechanism |
| -------------------------- | -------------------------------------------------- |
| Admin-editable preferences | `admin.settingsSchema` + `ctx.kv` with `settings:` |
| Internal plugin state | `ctx.kv` with `state:` |
| Collections of documents | `ctx.storage` |
**Settings** are for user-configurable values—things an admin might change. They get an auto-generated UI.
**KV** is for internal state like timestamps, sync cursors, or cached computations. No UI, just code.
**Storage** is for document collections with indexed queries—form submissions, audit logs, etc.
## Loading Settings in Routes
API routes can expose settings to admin UI components:
```typescript
routes: {
settings: {
handler: async (ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
const key = entry.key.replace("settings:", "");
result[key] = entry.value;
}
return result;
}
},
"settings/save": {
handler: async (ctx) => {
const input = ctx.input as Record<string, unknown>;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
}
}
}
```
## Default Values
Settings from `settingsSchema` are not automatically persisted. They're defaults in the admin UI. Your code should handle missing values:
```typescript
"content:afterSave": async (event, ctx) => {
// Always provide a fallback
const enabled = await ctx.kv.get<boolean>("settings:enabled") ?? true;
const maxItems = await ctx.kv.get<number>("settings:maxItems") ?? 100;
if (!enabled) return;
// ...
}
```
Alternatively, persist defaults in `plugin:install`:
```typescript
hooks: {
"plugin:install": async (_event, ctx) => {
// Persist schema defaults
await ctx.kv.set("settings:enabled", true);
await ctx.kv.set("settings:maxItems", 100);
}
}
```
## Storage Implementation
KV values are stored in the `_options` table with plugin-namespaced keys:
```sql
INSERT INTO _options (name, value) VALUES
('plugin:seo:settings:siteTitle', '"My Site"'),
('plugin:seo:settings:maxTitleLength', '60');
```
The `plugin:seo:` prefix is added automatically. Your code uses `settings:siteTitle`, and EmDash stores it as `plugin:seo:settings:siteTitle`.
This ensures plugins can't accidentally overwrite each other's data.

View File

@@ -0,0 +1,360 @@
---
title: Plugin Storage
description: Persist plugin data in document collections with indexed queries.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Plugins can store their own data in document collections without writing database migrations. Declare collections and indexes in your plugin definition, and EmDash handles the schema automatically.
## Declaring Storage
Define storage collections in `definePlugin()`:
```typescript
import { definePlugin } from "emdash";
export default definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: [
"formId", // Single-field index
"status",
"createdAt",
["formId", "createdAt"], // Composite index
["status", "createdAt"],
],
},
forms: {
indexes: ["slug"],
},
},
// ...
});
```
Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently.
<Aside type="note">
Storage is scoped to the plugin. A `submissions` collection in the `forms` plugin is completely
separate from `submissions` in another plugin.
</Aside>
## Storage Collection API
Access collections via `ctx.storage` in hooks and routes:
```typescript
"content:afterSave": async (event, ctx) => {
const { submissions } = ctx.storage;
// CRUD operations
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");
}
```
### Full API Reference
```typescript
interface StorageCollection<T = unknown> {
// Basic CRUD
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
// Batch operations
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
// Query (indexed fields only)
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
count(where?: WhereClause): Promise<number>;
}
```
## Querying Data
Use `query()` to retrieve documents matching criteria. Queries return paginated results.
```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 (if more results)
// result.hasMore - Boolean indicating more pages
```
### Query Options
```typescript
interface QueryOptions {
where?: WhereClause;
orderBy?: Record<string, "asc" | "desc">;
limit?: number; // Default 50, max 1000
cursor?: string; // For pagination
}
```
### Where Clause Operators
Filter by indexed fields using these operators:
<Tabs>
<TabItem label="Exact Match">
```typescript
where: {
status: "pending", // Exact string match
count: 5, // Exact number match
archived: false // Exact boolean match
}
```
</TabItem>
<TabItem label="Range">
```typescript
where: {
createdAt: { gte: "2024-01-01" }, // Greater than or equal
score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)
}
// Available: gt, gte, lt, lte
````
</TabItem>
<TabItem label="In">
```typescript
where: {
status: { in: ["pending", "approved"] }
}
````
</TabItem>
<TabItem label="Starts With">
```typescript
where: {
slug: { startsWith: "blog-" }
}
```
</TabItem>
</Tabs>
### Ordering
Order results by indexed fields:
```typescript
orderBy: {
createdAt: "desc";
} // Newest first
orderBy: {
score: "asc";
} // Lowest first
```
<Aside type="caution">
You can only query and order by indexed fields. Queries on non-indexed fields throw an error. This
prevents accidental full-table scans.
</Aside>
## Pagination
Results are paginated. Use `cursor` to fetch additional pages:
```typescript
async function getAllSubmissions(ctx: PluginContext) {
const allItems = [];
let cursor: string | undefined;
do {
const result = await ctx.storage.submissions!.query({
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
allItems.push(...result.items);
cursor = result.cursor;
} while (cursor);
return allItems;
}
```
### PaginatedResult
```typescript
interface PaginatedResult<T> {
items: T[];
cursor?: string; // Pass to next query for more results
hasMore: boolean; // True if more pages exist
}
```
## Counting Documents
Count documents matching criteria:
```typescript
// Count all
const total = await ctx.storage.submissions!.count();
// Count with filter
const pending = await ctx.storage.submissions!.count({
status: "pending",
});
```
## Batch Operations
For bulk operations, use batch methods:
```typescript
// Get multiple by ID
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
// Returns Map<string, T>
// Put multiple
await ctx.storage.submissions!.putMany([
{ id: "sub_1", data: { formId: "contact", status: "new" } },
{ id: "sub_2", data: { formId: "contact", status: "new" } },
]);
// Delete multiple
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
```
## Index Design
Choose indexes based on your query patterns:
| Query Pattern | Index Needed |
| ---------------------------------------- | ------------------------------------ |
| Filter by `formId` | `"formId"` |
| Filter by `formId`, order by `createdAt` | `["formId", "createdAt"]` |
| Order by `createdAt` only | `"createdAt"` |
| Filter by `status` and `formId` | `"status"` and `"formId"` (separate) |
Composite indexes support queries that filter on the first field and optionally order by the second:
```typescript
// With index ["formId", "createdAt"]:
// This works:
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
// This also works (filter only):
query({ where: { formId: "contact" } });
// This does NOT use the composite index (wrong field order):
query({ where: { createdAt: { gte: "2024-01-01" } } });
```
## Type Safety
Type your storage collections for better IntelliSense:
```typescript
interface Submission {
formId: string;
email: string;
data: Record<string, unknown>;
status: "pending" | "approved" | "spam";
createdAt: string;
}
definePlugin({
id: "forms",
version: "1.0.0",
storage: {
submissions: {
indexes: ["formId", "status", "createdAt"],
},
},
hooks: {
"content:afterSave": async (event, ctx) => {
// Cast to typed collection
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
const submission: Submission = {
formId: "contact",
email: "user@example.com",
data: { message: "Hello" },
status: "pending",
createdAt: new Date().toISOString(),
};
await submissions.put(`sub_${Date.now()}`, submission);
},
},
});
```
## Storage vs Content vs KV
Use the right storage mechanism for your use case:
| Use Case | Storage |
| -------------------------------------------------- | ------------------------------------- |
| Plugin operational data (logs, submissions, cache) | `ctx.storage` |
| User-configurable settings | `ctx.kv` with `settings:` prefix |
| Internal plugin state | `ctx.kv` with `state:` prefix |
| Content editable in admin UI | Site collections (not plugin storage) |
<Aside type="tip">
Plugin storage is for data the plugin owns and manages internally. If content editors need to view
or edit the data in the admin UI, create a site collection instead.
</Aside>
## Implementation Details
Under the hood, plugin storage uses a single database table:
```sql
CREATE TABLE _plugin_storage (
plugin_id TEXT NOT NULL,
collection TEXT NOT NULL,
id TEXT NOT NULL,
data JSON NOT NULL,
created_at TEXT,
updated_at TEXT,
PRIMARY KEY (plugin_id, collection, id)
);
```
EmDash creates expression indexes for declared fields:
```sql
CREATE INDEX idx_forms_submissions_formId
ON _plugin_storage(json_extract(data, '$.formId'))
WHERE plugin_id = 'forms' AND collection = 'submissions';
```
This design provides:
- **No migrations** — Schema lives in plugin code
- **Portability** — Works on D1, libSQL, SQLite
- **Isolation** — Plugins can only access their own data
- **Safety** — No SQL injection, validated queries
## Adding Indexes
When you add indexes in a plugin update, EmDash creates them automatically on next startup. This is safe—indexes can be added without data migration.
When you remove indexes, EmDash drops them. Queries on non-indexed fields will fail with a validation error.

View File

@@ -0,0 +1,493 @@
---
title: JavaScript API Reference
description: Programmatic API for querying and managing EmDash content.
---
import { Aside } from "@astrojs/starlight/components";
EmDash exports functions for querying content, managing media, and working with the database.
## Content Queries
EmDash's query functions follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning `{ entries, error }` or `{ entry, error }` for graceful error handling.
### `getEmDashCollection()`
Fetch all entries from a collection.
```ts
import { getEmDashCollection } from "emdash";
const { entries: posts, error } = await getEmDashCollection("posts");
if (error) {
console.error("Failed to load posts:", error);
}
```
#### Parameters
| Parameter | Type | Description |
| ------------ | ------------------ | ----------------------- |
| `collection` | `string` | Collection slug |
| `options` | `CollectionFilter` | Optional filter options |
#### Options
```ts
interface CollectionFilter {
status?: "draft" | "published" | "archived";
limit?: number;
where?: Record<string, string | string[]>; // Filter by field or taxonomy
}
```
#### Returns
```ts
interface CollectionResult<T> {
entries: ContentEntry<T>[]; // Empty array if error or none found
error?: Error; // Set if query failed
}
```
#### Examples
```ts
// Get all published posts
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Get latest 5 posts
const { entries: latest } = await getEmDashCollection("posts", {
limit: 5,
status: "published",
});
// Filter by taxonomy
const { entries: newsPosts } = await getEmDashCollection("posts", {
status: "published",
where: { category: "news" },
});
// Handle errors
const { entries, error } = await getEmDashCollection("posts");
if (error) {
return new Response("Server error", { status: 500 });
}
```
### `getEmDashEntry()`
Fetch a single entry by slug or ID.
```ts
import { getEmDashEntry } from "emdash";
const { entry: post, error } = await getEmDashEntry("posts", "my-post-slug");
if (!post) {
return Astro.redirect("/404");
}
```
#### Parameters
| Parameter | Type | Description |
| ------------ | -------- | ---------------- |
| `collection` | `string` | Collection slug |
| `slugOrId` | `string` | Entry slug or ID |
Preview mode is handled automatically — the middleware detects `_preview` tokens and serves draft content via `AsyncLocalStorage`. No options parameter is needed.
#### Returns
```ts
interface EntryResult<T> {
entry: ContentEntry<T> | null; // null if not found
error?: Error; // Set only for actual errors, not "not found"
isPreview: boolean; // true if draft content is being served
}
```
#### Examples
```ts
// Get by slug
const { entry: post } = await getEmDashEntry("posts", "hello-world");
// Get by ID
const { entry: post } = await getEmDashEntry("posts", "01HXK5MZSN0FVXT2Q3KPRT9M7D");
// Preview is automatic — isPreview is true when a valid _preview token is present
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
// Handle errors vs not-found
if (error) {
return new Response("Server error", { status: 500 });
}
if (!entry) {
return Astro.redirect("/404");
}
```
## Content Types
### `ContentEntry`
The entry returned by query functions:
```ts
interface ContentEntry<T = Record<string, unknown>> {
id: string;
data: T;
edit: EditProxy; // Visual editing annotations
}
```
The `edit` proxy provides visual editing annotations. Spread it onto elements to enable inline editing: `{...entry.edit.title}`. In production, this produces no output.
The `data` object contains all content fields plus system fields:
- `id` - Unique identifier
- `slug` - URL-friendly identifier
- `status` - "draft" | "published" | "archived"
- `createdAt` - ISO timestamp
- `updatedAt` - ISO timestamp
- `publishedAt` - ISO timestamp or null
- Plus all custom fields defined in your collection schema
## Database Functions
### `createDatabase()`
Create a database connection.
```ts
import { createDatabase } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
```
<Aside type="caution">
Direct database access is for advanced use cases. Prefer the query functions for content access.
</Aside>
### `runMigrations()`
Run pending database migrations.
```ts
import { createDatabase, runMigrations } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
const { applied } = await runMigrations(db);
console.log(`Applied ${applied.length} migrations`);
```
### `getMigrationStatus()`
Check migration status.
```ts
import { createDatabase, getMigrationStatus } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
const status = await getMigrationStatus(db);
// { applied: ["0001_core", ...], pending: [] }
```
## Repositories
Low-level data access through repositories.
### `ContentRepository`
```ts
import { ContentRepository, createDatabase } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
const repo = new ContentRepository(db);
// Find many
const { items, nextCursor } = await repo.findMany("posts", {
limit: 10,
where: { status: "published" },
});
// Find by ID
const post = await repo.findById("posts", "01HXK5MZSN...");
// Create
const newPost = await repo.create({
type: "posts",
slug: "new-post",
data: { title: "New Post", content: [] },
status: "draft",
});
// Update
const updated = await repo.update("posts", "01HXK5MZSN...", {
data: { title: "Updated Title" },
});
// Delete
await repo.delete("posts", "01HXK5MZSN...");
```
### `MediaRepository`
```ts
import { MediaRepository, createDatabase } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
const repo = new MediaRepository(db);
// List media
const { items } = await repo.findMany({ limit: 20 });
// Get by ID
const media = await repo.findById("01HXK5MZSN...");
// Create (after upload)
const newMedia = await repo.create({
filename: "photo.jpg",
mimeType: "image/jpeg",
size: 12345,
storageKey: "uploads/photo.jpg",
});
```
## Schema Registry
Programmatic schema management.
```ts
import { SchemaRegistry, createDatabase } from "emdash";
const db = createDatabase({ url: "file:./data.db" });
const registry = new SchemaRegistry(db);
// List collections
const collections = await registry.listCollections();
// Get collection with fields
const postsSchema = await registry.getCollectionWithFields("posts");
// Create collection
await registry.createCollection({
slug: "products",
label: "Products",
labelSingular: "Product",
supports: ["drafts", "revisions"],
});
// Add field
await registry.createField("products", {
slug: "price",
label: "Price",
type: "number",
required: true,
});
```
## Preview System
### `generatePreviewToken()`
Generate a preview token for draft content.
```ts
import { generatePreviewToken } from "emdash";
const token = await generatePreviewToken({
contentId: "posts:01HXK5MZSN...",
secret: process.env.EMDASH_ADMIN_SECRET,
expiresIn: 3600, // 1 hour
});
```
### `verifyPreviewToken()`
Verify a preview token.
```ts
import { verifyPreviewToken } from "emdash";
const result = await verifyPreviewToken({
token,
secret: process.env.EMDASH_ADMIN_SECRET,
});
if (result.valid) {
const { cid, exp, iat } = result.payload;
// cid is "collection:id" format, e.g. "posts:my-draft-post"
}
```
### `isPreviewRequest()`
Check if a request includes a preview token.
```ts
import { isPreviewRequest, getPreviewToken } from "emdash";
if (isPreviewRequest(Astro.request)) {
const token = getPreviewToken(Astro.request);
// Verify and show preview content
}
```
## Content Converters
Convert between Portable Text and ProseMirror formats.
```ts
import { prosemirrorToPortableText, portableTextToProsemirror } from "emdash";
// From ProseMirror (editor) to Portable Text (storage)
const portableText = prosemirrorToPortableText(prosemirrorDoc);
// From Portable Text to ProseMirror
const prosemirrorDoc = portableTextToProsemirror(portableText);
```
## Site Settings
```ts
import { getSiteSettings, getSiteSetting } from "emdash";
// Get all settings
const settings = await getSiteSettings();
// Get single setting
const title = await getSiteSetting("siteTitle");
```
Settings are read-only from the runtime API. Use the admin API to update them.
## Menus
```ts
import { getMenu, getMenus } from "emdash";
// Get all menus
const menus = await getMenus();
// Get specific menu with items
const primaryMenu = await getMenu("primary");
if (primaryMenu) {
primaryMenu.items.forEach(item => {
console.log(item.label, item.url);
// Nested items for dropdowns
item.children.forEach(child => console.log(" -", child.label));
});
}
```
## Taxonomies
```ts
import { getTaxonomyTerms, getTerm, getEntryTerms, getEntriesByTerm } from "emdash";
// Get all terms for a taxonomy (tree structure for hierarchical)
const categories = await getTaxonomyTerms("category");
// Get single term
const news = await getTerm("category", "news");
// Get terms assigned to a content entry
const postCategories = await getEntryTerms("posts", "post-123", "category");
// Get entries with a specific term
const newsPosts = await getEntriesByTerm("posts", "category", "news");
```
## Widget Areas
```ts
import { getWidgetArea, getWidgetAreas } from "emdash";
// Get all widget areas
const areas = await getWidgetAreas();
// Get specific widget area with widgets
const sidebar = await getWidgetArea("sidebar");
if (sidebar) {
sidebar.widgets.forEach(widget => {
console.log(widget.type, widget.title);
});
}
```
## Sections
```ts
import { getSection, getSections, getSectionCategories } from "emdash";
// Get all sections
const sections = await getSections();
// Filter sections
const heroes = await getSections({ category: "hero" });
const themeSections = await getSections({ source: "theme" });
const results = await getSections({ search: "newsletter" });
// Get single section
const cta = await getSection("newsletter-cta");
// Get categories
const categories = await getSectionCategories();
```
## Search
```ts
import { search, searchCollection } from "emdash";
// Global search across collections
const results = await search("hello world", {
collections: ["posts", "pages"],
status: "published",
limit: 20,
});
// Results include snippets with highlights
results.forEach(result => {
console.log(result.title);
console.log(result.snippet); // Contains <mark> tags
console.log(result.score);
});
// Collection-specific search
const posts = await searchCollection("posts", "typescript", {
limit: 10,
});
```
## Error Handling
EmDash exports error classes for handling specific failures:
```ts
import {
EmDashDatabaseError,
EmDashValidationError,
EmDashStorageError,
SchemaError,
} from "emdash";
try {
await repo.create({ ... });
} catch (error) {
if (error instanceof EmDashValidationError) {
console.error("Validation failed:", error.message);
}
if (error instanceof SchemaError) {
console.error("Schema error:", error.code, error.details);
}
}
```

View File

@@ -0,0 +1,647 @@
---
title: CLI Reference
description: Command-line interface for EmDash CMS.
---
import { Aside } from "@astrojs/starlight/components";
The EmDash CLI provides commands for managing an EmDash CMS instance — database setup, type generation, content CRUD, schema management, media, and more.
## Installation
The CLI is included with the `emdash` package:
```bash
npm install emdash
```
Run commands with `npx emdash` or add scripts to `package.json`. The binary is also available as `em` for brevity.
## Authentication
Commands that talk to a running EmDash instance (everything except `init`, `seed`, `export-seed`, and `secrets`) resolve authentication in this order:
1. **`--token` flag** — explicit token on the command line
2. **`EMDASH_TOKEN` env var**
3. **Stored credentials** from `~/.config/emdash/auth.json` (saved by `emdash login`)
4. **Dev bypass** — if the URL is localhost and no token is available, automatically authenticates via the dev bypass endpoint
Most commands accept `--url` (default `http://localhost:4321`) and `--token` flags. When targeting a local dev server, no token is needed.
## Common Flags
These flags are available on all remote commands:
| Flag | Alias | Description | Default |
| --------- | ----- | --------------------------- | ------------------------ |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
| `--token` | `-t` | Auth token | From env/stored creds |
| `--json` | | Output as JSON (for piping) | Auto-detected from TTY |
## Output
When stdout is a TTY, the CLI pretty-prints results with consola. When piped or when `--json` is set, it outputs raw JSON to stdout — suitable for `jq` or other tools.
## Commands
### `emdash init`
Initialize the database with core schema and optional template data.
```bash
npx emdash init [options]
```
#### Options
| Option | Alias | Description | Default |
| ------------ | ----- | ---------------------- | ----------------- |
| `--database` | `-d` | Database file path | `./data.db` |
| `--cwd` | | Working directory | Current directory |
| `--force` | `-f` | Re-run schema and seed | `false` |
#### Behavior
1. Reads `emdash` config from `package.json`
2. Creates the database file if needed
3. Runs core migrations (creates system tables)
4. Runs template `schema.sql` if configured
5. Runs template `seed.sql` if configured
<Aside>
If the database already contains collections, `init` skips schema and seed unless `--force` is
used.
</Aside>
### `emdash dev`
Start the development server with automatic database setup.
```bash
npx emdash dev [options]
```
#### Options
| Option | Alias | Description | Default |
| ------------ | ----- | ---------------------------------------- | ----------------- |
| `--database` | `-d` | Database file path | `./data.db` |
| `--types` | `-t` | Generate types from remote before starting | `false` |
| `--port` | `-p` | Dev server port | `4321` |
| `--cwd` | | Working directory | Current directory |
#### Examples
```bash
# Start dev server
npx emdash dev
# Custom port
npx emdash dev --port 3000
# Generate types from remote before starting
npx emdash dev --types
```
#### Behavior
1. Checks for and runs pending database migrations
2. If `--types` is set, generates TypeScript types from a remote instance (URL from `EMDASH_URL` env or `emdash.url` in `package.json`)
3. Starts Astro dev server with `EMDASH_DATABASE_URL` set
### `emdash types`
Generate TypeScript types from a running EmDash instance's schema.
```bash
npx emdash types [options]
```
#### Options
| Option | Alias | Description | Default |
| ---------- | ----- | ------------------------ | -------------------- |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
| `--token` | `-t` | Auth token | From env/stored creds |
| `--output` | `-o` | Output path for types | `.emdash/types.ts` |
| `--cwd` | | Working directory | Current directory |
#### Examples
```bash
# Generate types from local dev server
npx emdash types
# Generate from remote instance
npx emdash types --url https://my-site.pages.dev
# Custom output path
npx emdash types --output src/types/emdash.ts
```
#### Behavior
1. Fetches the schema from the instance
2. Generates TypeScript type definitions
3. Writes types to the output file
4. Writes `schema.json` alongside for reference
### `emdash login`
Log in to an EmDash instance using OAuth Device Flow.
```bash
npx emdash login [options]
```
#### Options
| Option | Alias | Description | Default |
| ------- | ----- | --------------------- | ---------------------- |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
#### Behavior
1. Discovers auth endpoints from the instance
2. If localhost and no auth configured, uses dev bypass automatically
3. Otherwise initiates OAuth Device Flow — displays a code and opens your browser
4. Polls for authorization, then saves credentials to `~/.config/emdash/auth.json`
Saved credentials are used automatically by all subsequent commands targeting the same instance.
### `emdash logout`
Log out and remove stored credentials.
```bash
npx emdash logout [options]
```
#### Options
| Option | Alias | Description | Default |
| ------- | ----- | --------------------- | ---------------------- |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
### `emdash whoami`
Show the current authenticated user.
```bash
npx emdash whoami [options]
```
#### Options
| Option | Alias | Description | Default |
| --------- | ----- | --------------------- | ---------------------- |
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
| `--token` | `-t` | Auth token | From env/stored creds |
| `--json` | | Output as JSON | |
Displays email, name, role, auth method, and instance URL.
### `emdash content`
Manage content items. All subcommands use the remote API via `EmDashClient`.
#### `content list <collection>`
```bash
npx emdash content list posts
npx emdash content list posts --status published --limit 10
```
| Option | Description |
| ---------- | -------------------- |
| `--status` | Filter by status |
| `--limit` | Maximum items |
| `--cursor` | Pagination cursor |
#### `content get <collection> <id>`
```bash
npx emdash content get posts 01ABC123
npx emdash content get posts 01ABC123 --raw
```
| Option | Description |
| ------- | ------------------------------------------------ |
| `--raw` | Return raw Portable Text (skip markdown conversion) |
The response includes a `_rev` token — pass it to `content update` to prove you've seen what you're overwriting.
#### `content create <collection>`
```bash
npx emdash content create posts --data '{"title": "Hello"}'
npx emdash content create posts --file post.json --slug hello-world
cat post.json | npx emdash content create posts --stdin
```
| Option | Description |
| ---------- | ------------------------------ |
| `--data` | JSON string with content data |
| `--file` | Read data from a JSON file |
| `--stdin` | Read data from stdin |
| `--slug` | Content slug |
| `--status` | Initial status (draft, published) |
Provide data via exactly one of `--data`, `--file`, or `--stdin`.
#### `content update <collection> <id>`
Like a file editor that requires you to read before you write — you must provide the `_rev` token from a prior `get` to prove you've seen the current state. This prevents accidentally overwriting changes you haven't seen.
```bash
# 1. Read the item, note the _rev
npx emdash content get posts 01ABC123
# 2. Update with the _rev from step 1
npx emdash content update posts 01ABC123 \
--rev MToyMDI2LTAyLTE0... \
--data '{"title": "Updated"}'
```
| Option | Description |
| -------- | -------------------------------------- |
| `--rev` | Revision token from `get` (required) |
| `--data` | JSON string with content data |
| `--file` | Read data from a JSON file |
If the item has changed since your `get`, the server returns 409 Conflict — re-read and try again.
#### `content delete <collection> <id>`
```bash
npx emdash content delete posts 01ABC123
```
Soft-deletes the content item (moves to trash).
#### `content publish <collection> <id>`
```bash
npx emdash content publish posts 01ABC123
```
#### `content unpublish <collection> <id>`
```bash
npx emdash content unpublish posts 01ABC123
```
#### `content schedule <collection> <id>`
```bash
npx emdash content schedule posts 01ABC123 --at 2026-03-01T09:00:00Z
```
| Option | Description |
| ------ | ------------------------------- |
| `--at` | ISO 8601 datetime (required) |
#### `content restore <collection> <id>`
```bash
npx emdash content restore posts 01ABC123
```
Restores a trashed content item.
### `emdash schema`
Manage collections and fields.
#### `schema list`
```bash
npx emdash schema list
```
Lists all collections.
#### `schema get <collection>`
```bash
npx emdash schema get posts
```
Shows a collection with all its fields.
#### `schema create <collection>`
```bash
npx emdash schema create articles --label Articles
npx emdash schema create articles --label Articles --label-singular Article --description "Blog articles"
```
| Option | Description |
| ------------------ | ------------------------------- |
| `--label` | Collection label (required) |
| `--label-singular` | Singular label |
| `--description` | Collection description |
#### `schema delete <collection>`
```bash
npx emdash schema delete articles
npx emdash schema delete articles --force
```
| Option | Description |
| --------- | ------------------- |
| `--force` | Skip confirmation |
Prompts for confirmation unless `--force` is set.
#### `schema add-field <collection> <field>`
```bash
npx emdash schema add-field posts body --type portableText --label "Body Content"
npx emdash schema add-field posts featured --type boolean --required
```
| Option | Description |
| ------------ | ------------------------------------------------------------------------------------------------ |
| `--type` | Field type: string, text, number, integer, boolean, datetime, image, reference, portableText, json (required) |
| `--label` | Field label (defaults to field slug) |
| `--required` | Whether the field is required |
#### `schema remove-field <collection> <field>`
```bash
npx emdash schema remove-field posts featured
```
### `emdash media`
Manage media items.
#### `media list`
```bash
npx emdash media list
npx emdash media list --mime image/png --limit 20
```
| Option | Description |
| ---------- | ------------------------ |
| `--mime` | Filter by MIME type |
| `--limit` | Number of items |
| `--cursor` | Pagination cursor |
#### `media upload <file>`
```bash
npx emdash media upload ./photo.jpg
npx emdash media upload ./photo.jpg --alt "A sunset" --caption "Taken in Bristol"
```
| Option | Description |
| ----------- | -------------- |
| `--alt` | Alt text |
| `--caption` | Caption text |
#### `media get <id>`
```bash
npx emdash media get 01MEDIA123
```
#### `media delete <id>`
```bash
npx emdash media delete 01MEDIA123
```
### `emdash search`
Full-text search across content.
```bash
npx emdash search "hello world"
npx emdash search "hello" --collection posts --limit 5
```
| Option | Alias | Description |
| -------------- | ----- | -------------------- |
| `--collection` | `-c` | Filter by collection |
| `--limit` | `-l` | Maximum results |
### `emdash taxonomy`
Manage taxonomies and terms.
#### `taxonomy list`
```bash
npx emdash taxonomy list
```
#### `taxonomy terms <name>`
```bash
npx emdash taxonomy terms categories
npx emdash taxonomy terms tags --limit 50
```
| Option | Alias | Description |
| ---------- | ----- | ----------------- |
| `--limit` | `-l` | Maximum terms |
| `--cursor` | | Pagination cursor |
#### `taxonomy add-term <taxonomy>`
```bash
npx emdash taxonomy add-term categories --name "Tech" --slug tech
npx emdash taxonomy add-term categories --name "Frontend" --parent 01PARENT123
```
| Option | Description |
| ---------- | ---------------------------------------- |
| `--name` | Term label (required) |
| `--slug` | Term slug (defaults to slugified name) |
| `--parent` | Parent term ID (for hierarchical taxonomies) |
### `emdash menu`
Manage navigation menus.
#### `menu list`
```bash
npx emdash menu list
```
#### `menu get <name>`
```bash
npx emdash menu get primary
```
Returns the menu with all its items.
### `emdash seed`
Apply a seed file to the database. This command works directly on a local SQLite file (no running server needed).
```bash
npx emdash seed [path] [options]
```
#### Arguments
| Argument | Description | Default |
| -------- | ----------------- | --------------------- |
| `path` | Path to seed file | `.emdash/seed.json` |
#### Options
| Option | Alias | Description | Default |
| ------------------ | ----- | --------------------------------------- | --------------------------- |
| `--database` | `-d` | Database file path | `./data.db` |
| `--cwd` | | Working directory | Current directory |
| `--validate` | | Validate only, don't apply | `false` |
| `--no-content` | | Skip sample content | `false` |
| `--on-conflict` | | Conflict handling: skip, update, error | `skip` |
| `--uploads-dir` | | Directory for media uploads | `.emdash/uploads` |
| `--media-base-url` | | Base URL for media files | `/_emdash/api/media/file` |
| `--base-url` | | Site base URL (for absolute media URLs) | |
#### Seed File Resolution
The command looks for seed files in this order:
1. Positional argument (if provided)
2. `.emdash/seed.json` (convention)
3. Path from `package.json` `emdash.seed` field
### `emdash export-seed`
Export database schema and content as a seed file. Works directly on a local SQLite file.
```bash
npx emdash export-seed [options] > seed.json
```
#### Options
| Option | Alias | Description | Default |
| ---------------- | ----- | ---------------------------------------------------- | ----------------- |
| `--database` | `-d` | Database file path | `./data.db` |
| `--cwd` | | Working directory | Current directory |
| `--with-content` | | Include content (all or comma-separated collections) | |
| `--no-pretty` | | Disable JSON formatting | `false` |
#### Output Format
The exported seed file includes:
- **Settings**: Site title, tagline, social links
- **Collections**: All collection definitions with fields
- **Taxonomies**: Taxonomy definitions and terms
- **Menus**: Navigation menus with items
- **Widget Areas**: Widget areas and widgets
- **Content** (if requested): Entries with `$media` references and `$ref:` syntax for portability
### `emdash secrets generate`
Generate an `EMDASH_ENCRYPTION_KEY` for your deployment. The key is used to
encrypt plugin secrets at rest.
```bash
npx emdash secrets generate
```
Prints the new key to stdout. Pipe it into your secret store, or write it
straight to a dev file with `--write`:
```bash
npx emdash secrets generate --write .dev.vars
npx emdash secrets generate --write .env
```
`--write` refuses to overwrite an existing entry without `--force`.
Replacing a key in a deployment with existing encrypted data will leave
those secrets unreadable, so the protection is intentional.
### `emdash secrets fingerprint <key>`
Print the 8-character fingerprint (kid) of a key without exposing its
value. Useful in CI for verifying the right key was deployed:
```bash
npx emdash secrets fingerprint emdash_enc_v1_...
```
## Generated Files
### `.emdash/types.ts`
TypeScript interfaces generated by `emdash types`:
```ts
// Generated by EmDash CLI
// Do not edit manually - run `emdash types` to regenerate
import type { PortableTextBlock } from "emdash";
export interface Post {
id: string;
title: string;
content: PortableTextBlock[];
publishedAt: Date | null;
}
```
### `.emdash/schema.json`
Raw schema export for tooling:
```json
{
"version": "a1b2c3d4",
"collections": [
{
"slug": "posts",
"label": "Posts",
"fields": [...]
}
]
}
```
## Environment Variables
| Variable | Description |
| ------------------------- | ---------------------------------------- |
| `EMDASH_DATABASE_URL` | Database URL (set automatically by `dev`) |
| `EMDASH_TOKEN` | Auth token for remote operations |
| `EMDASH_URL` | Default remote URL for `types` and `dev --types` |
| `EMDASH_ENCRYPTION_KEY` | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. Generate with `emdash secrets generate`. |
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, EmDash generates and persists one in the options table. |
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, EmDash generates and persists one in the options table. |
| `EMDASH_AUTH_SECRET` | Legacy. Used as the IP-salt source if set, so existing installs keep stable commenter-IP hashes across upgrade. New installs should not set this. |
## Package Scripts
```json
{
"scripts": {
"dev": "emdash dev",
"init": "emdash init",
"types": "emdash types",
"seed": "emdash seed",
"export-seed": "emdash export-seed",
"db:reset": "rm -f data.db && emdash init"
}
}
```
## Exit Codes
| Code | Description |
| ---- | ---------------------------------------- |
| `0` | Success |
| `1` | Error (configuration, network, database) |

View File

@@ -0,0 +1,646 @@
---
title: Configuration Reference
description: Complete reference for EmDash configuration options.
---
import { Aside } from "@astrojs/starlight/components";
EmDash is configured through two files: `astro.config.mjs` for the integration and `src/live.config.ts` for content collections.
## Astro Integration
Configure EmDash as an Astro integration:
```js title="astro.config.mjs"
import { defineConfig } from "astro/config";
import emdash, { local, r2, s3 } from "emdash/astro";
import { sqlite, libsql, d1 } from "emdash/db";
export default defineConfig({
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
plugins: [],
}),
],
});
```
## Integration Options
### `database`
**Required.** Database adapter configuration.
```js
// SQLite (Node.js)
database: sqlite({ url: "file:./data.db" });
// PostgreSQL
database: postgres({ connectionString: process.env.DATABASE_URL });
// libSQL
database: libsql({
url: process.env.LIBSQL_DATABASE_URL,
authToken: process.env.LIBSQL_AUTH_TOKEN,
});
// Cloudflare D1 (import from @emdash-cms/cloudflare)
database: d1({ binding: "DB" });
```
See [Database Options](/deployment/database/) for details.
### `storage`
**Required.** Media storage adapter configuration.
```js
// Local filesystem (development)
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
// R2 binding (Cloudflare Workers)
storage: r2({
binding: "MEDIA",
publicUrl: "https://pub-xxxx.r2.dev", // optional
});
// S3-compatible (any platform) — all fields from S3_* environment variables
storage: s3()
// Or with explicit values
storage: s3({
endpoint: "https://s3.amazonaws.com",
bucket: "my-bucket",
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
region: "us-east-1", // optional, default: "auto"
publicUrl: "https://cdn.example.com", // optional
});
```
See [Storage Options](/deployment/storage/) for details.
### `plugins`
**Optional.** Array of EmDash plugins.
```js
import seoPlugin from "@emdash-cms/plugin-seo";
plugins: [seoPlugin()];
```
### `fonts`
**Optional.** Admin UI font configuration.
By default, EmDash loads [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) via the [Astro Font API](https://docs.astro.build/en/guides/fonts/). Fonts are downloaded from Google at build time and self-hosted, so there are no runtime CDN requests. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese scripts.
To add support for additional writing systems, pass script names:
```js
emdash({
fonts: {
scripts: ["arabic", "japanese"],
},
})
```
Available scripts: `arabic`, `armenian`, `bengali`, `chinese-simplified`, `chinese-traditional`, `chinese-hongkong`, `devanagari`, `ethiopic`, `georgian`, `gujarati`, `gurmukhi`, `hebrew`, `japanese`, `kannada`, `khmer`, `korean`, `lao`, `malayalam`, `myanmar`, `oriya`, `sinhala`, `tamil`, `telugu`, `thai`, `tibetan`.
Each script maps to the corresponding Noto Sans variant on Google Fonts (e.g. `"arabic"` loads Noto Sans Arabic). All font faces share a single `font-family` name and use `unicode-range` so the browser only downloads the files it needs for the characters on the page.
Set to `false` to disable font injection entirely and use system fonts:
```js
emdash({
fonts: false,
})
```
The admin CSS uses the `--font-emdash` CSS variable. This is set automatically by the font configuration above.
### `auth`
**Optional.** Authentication configuration.
```js
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { atproto } from "@emdash-cms/auth-atproto";
emdash({
auth: {
// Self-signup configuration
selfSignup: {
domains: ["example.com"],
defaultRole: 20, // Contributor
},
// Session configuration
session: {
maxAge: 30 * 24 * 60 * 60, // 30 days
sliding: true, // Reset expiry on activity
},
// OR use Cloudflare Access (exclusive mode)
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com",
audience: "your-app-audience-tag",
autoProvision: true,
defaultRole: 30,
syncRoles: false,
roleMapping: {
Admins: 50,
Editors: 40,
},
},
},
// Pluggable login providers (top-level, not nested under `auth`)
authProviders: [github(), google(), atproto()],
});
```
#### `auth.selfSignup`
Allow users to self-register if their email domain is allowed.
| Option | Type | Default | Description |
| ------------- | ---------- | ------- | ------------------------- |
| `domains` | `string[]` | `[]` | Allowed email domains |
| `defaultRole` | `number` | `20` | Role for self-signups |
```js
selfSignup: {
domains: ["example.com", "acme.org"],
defaultRole: 20, // Contributor
}
```
### `authProviders`
**Optional.** An array of pluggable login providers (top-level, alongside `auth`). Each entry is the result of calling a provider factory:
```js
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { atproto } from "@emdash-cms/auth-atproto";
emdash({
authProviders: [github(), google(), atproto()],
});
```
Built-in providers:
- `github()` — reads `EMDASH_OAUTH_GITHUB_CLIENT_ID` / `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` (or unprefixed fallbacks).
- `google()` — reads `EMDASH_OAUTH_GOOGLE_CLIENT_ID` / `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET`.
- `atproto()` — Atmosphere/AT Protocol login. No env vars needed. Accepts `{ allowedDIDs, allowedHandles, defaultRole }`. See the [Atmosphere login guide](/guides/atmosphere-auth/).
Third-party packages can register their own providers using the same `AuthProviderDescriptor` shape — see [Login Providers](/guides/authentication/#login-providers).
#### `auth.session`
Session configuration.
| Option | Type | Default | Description |
| --------- | --------- | ----------------- | ------------------------------ |
| `maxAge` | `number` | `2592000` (30d) | Session lifetime in seconds |
| `sliding` | `boolean` | `true` | Reset expiry on activity |
#### `auth.cloudflareAccess`
Use Cloudflare Access as the authentication provider instead of passkeys.
| Option | Type | Default | Description |
| --------------- | --------- | -------- | ------------------------------ |
| `teamDomain` | `string` | required | Your Access team domain |
| `audience` | `string` | required | Application Audience (AUD) tag |
| `autoProvision` | `boolean` | `true` | Create users on first login |
| `defaultRole` | `number` | `30` | Default role for new users |
| `syncRoles` | `boolean` | `false` | Update role on each login |
| `roleMapping` | `object` | — | Map IdP groups to roles |
<Aside>
When `cloudflareAccess` is configured, it becomes the exclusive auth method. Passkeys, OAuth,
magic links, and self-signup are disabled.
</Aside>
### `siteUrl`
**Optional.** The public browser-facing origin for the site (scheme + host + optional port, **no path**).
Behind a **TLS-terminating reverse proxy**, `Astro.url` returns the internal address (`http://localhost:4321`) instead of the public one (`https://cms.example.com`). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set `siteUrl` to fix all of these at once.
The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **origin** (path is stripped).
```js
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
siteUrl: "https://cms.example.com",
});
```
When `siteUrl` is not set in config, EmDash checks environment variables in order: `EMDASH_SITE_URL`, then `SITE_URL`. This is useful for container deployments where the public URL is set at runtime.
<Aside type="tip">
`siteUrl` replaces `passkeyPublicOrigin` (removed). If you were using `passkeyPublicOrigin`, rename it to `siteUrl` -- it now covers passkeys and all other origin-dependent features.
</Aside>
#### Multi-origin passkey verification
`siteUrl` defines a single canonical origin. When the same EmDash deployment is reachable under several hostnames that share a registrable parent domain (e.g. `https://example.com` and `https://preview.example.com`), passkey verification rejects assertions whose origin doesn't match `siteUrl` exactly -- even though WebAuthn allows passkeys to be valid across subdomains under the same `rpId`.
Declare additional accepted origins via either `allowedOrigins` in `astro.config.mjs` or the `EMDASH_ALLOWED_ORIGINS` env var. The canonical `siteUrl` remains the source of `rpId`; entries listed here are accepted at verification time. The two sources are merged at runtime, so config can declare the stable origins (versioned, code-reviewed) while env adds environment-specific extras (e.g. ephemeral PR previews).
```js title="astro.config.mjs"
emdash({
siteUrl: "https://example.com",
allowedOrigins: ["https://preview.example.com"],
})
```
```bash title=".env / wrangler.jsonc / Docker env"
EMDASH_SITE_URL=https://example.com
EMDASH_ALLOWED_ORIGINS=https://preview.example.com,https://staging.example.com
```
##### Validation
EmDash validates these to prevent dead config the browser would never honor:
- Each entry must be a parseable `http:` or `https:` URL with no trailing dot and no empty labels in the hostname.
- When `allowedOrigins` is non-empty, `siteUrl` must be set (either source) and must not be an IP literal or have a trailing-dot hostname.
- Each origin must be the same hostname as `siteUrl` or a subdomain of it. (WebAuthn requires `rpId` to be a registrable suffix of every origin.)
When validation fails, you'll see a source-attributed error like `EmDash config error in EMDASH_ALLOWED_ORIGINS: "https://other-site.com" is not a subdomain of siteUrl "https://example.com". Allowed origins must be the same hostname as siteUrl or a subdomain of it.`
Where the error surfaces depends on where the values are declared:
- **At Astro startup**, when both `config.allowedOrigins` and `config.siteUrl` come from `astro.config.mjs` -- typos in code fail the build.
- **At first passkey verification**, when either value comes from `EMDASH_ALLOWED_ORIGINS` or `EMDASH_SITE_URL` -- env mismatches surface as 500s on the first verify attempt.
#### Reverse proxy setup
Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) for the hostname (and schemes) your users hit. In **`astro dev`**, add matching **`vite.server.allowedHosts`** so Vite accepts the proxy **`Host`** header.
Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`siteUrl`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).
With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`siteUrl`** matches the public HTTPS origin.
If your proxy writes a client-IP header, set [**`trustedProxyHeaders`**](#trustedproxyheaders) so EmDash's rate limits can use the real client IP instead of bucketing every request under a shared "unknown" key.
<Aside type="caution">
Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astros rebuilt URL can be wrong.
</Aside>
```js title="astro.config.mjs (excerpt)"
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
security: {
allowedDomains: [
{ hostname: "cms.example.com", protocol: "https" },
{ hostname: "cms.example.com", protocol: "http" },
],
},
vite: {
server: {
allowedHosts: ["cms.example.com"],
},
},
integrations: [
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
siteUrl: "https://cms.example.com",
}),
],
});
```
### `trustedProxyHeaders`
**Optional.** Headers to trust for client-IP resolution when running behind a reverse proxy you control. Used by auth rate limits (magic-link, signup, passkey, OAuth device flow) and the public comment endpoint.
On Cloudflare the `cf` object attached to the request is used automatically — you normally do **not** need to set this. On self-hosted deployments behind nginx, Caddy, Traefik, Fly, Railway, or similar, set this to the header your proxy writes so rate limits can bucket by real client IP instead of treating every request as "unknown".
```js
emdash({
database: sqlite({ url: "file:./data.db" }),
// nginx / Caddy / Traefik
trustedProxyHeaders: ["x-real-ip"],
});
```
Headers are tried in order. Values matching `*-forwarded-for` are parsed as comma-separated lists and the first entry is used.
```js
emdash({
// Fly.io, falling back to generic X-Forwarded-For
trustedProxyHeaders: ["fly-client-ip", "x-forwarded-for"],
});
```
When not set in config, EmDash reads the `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated). An explicit empty array in config overrides the env var.
<Aside type="caution">
**Only set this when you control the reverse proxy.** Untrusted clients can set any header
they like; trusting forwarded-IP headers from an open network is an IP-spoofing vulnerability
that defeats rate limiting. If EmDash is exposed directly to the public internet with no
proxy in front, leave this unset — rate limits will fall back to a shared "unknown" bucket
(stricter defaults) rather than trust a spoofable header.
</Aside>
### `maxUploadSize`
**Optional.** Maximum allowed media file upload size in bytes. Applies to both direct multipart uploads and signed-URL uploads. Defaults to `52_428_800` (50 MB).
```js
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
maxUploadSize: 100 * 1024 * 1024, // 100 MB
});
```
| Value | Description |
| ----- | ----------- |
| `number` (bytes) | Must be a positive finite integer |
| omitted | Defaults to 50 MB |
Uploads that exceed the configured limit are rejected with a `413 Payload Too Large` response on the direct upload path, or a `400 Validation Error` on the signed-URL path.
## Database Adapters
Import from `emdash/db`:
```js
import { sqlite, libsql, postgres, d1 } from "emdash/db";
```
### `sqlite(config)`
SQLite database using better-sqlite3.
| Option | Type | Description |
| ------ | -------- | ----------------------------- |
| `url` | `string` | File path with `file:` prefix |
```js
sqlite({ url: "file:./data.db" });
```
### `libsql(config)`
libSQL database.
| Option | Type | Description |
| ----------- | -------- | ------------------------------------- |
| `url` | `string` | Database URL |
| `authToken` | `string` | Auth token (optional for local files) |
```js
libsql({
url: process.env.LIBSQL_DATABASE_URL,
authToken: process.env.LIBSQL_AUTH_TOKEN,
});
```
### `postgres(config)`
PostgreSQL database with connection pooling.
| Option | Type | Description |
| ------------------ | --------- | ------------------------------------ |
| `connectionString` | `string` | PostgreSQL connection URL |
| `host` | `string` | Database host |
| `port` | `number` | Database port |
| `database` | `string` | Database name |
| `user` | `string` | Database user |
| `password` | `string` | Database password |
| `ssl` | `boolean` | Enable SSL |
| `pool.min` | `number` | Minimum pool size (default: 0) |
| `pool.max` | `number` | Maximum pool size (default: 10) |
```js
postgres({ connectionString: process.env.DATABASE_URL });
```
### `d1(config)`
Cloudflare D1 database. Import from `@emdash-cms/cloudflare`.
| Option | Type | Default | Description |
| ---------------- | -------- | -------------------- | --------------------------------------------------- |
| `binding` | `string` | — | D1 binding name from `wrangler.jsonc` |
| `session` | `string` | `"disabled"` | Read replication mode: `"disabled"`, `"auto"`, or `"primary-first"` |
| `bookmarkCookie` | `string` | `"__em_d1_bookmark"` | Cookie name for session bookmarks |
```js
// Basic
d1({ binding: "DB" });
// With read replicas
d1({ binding: "DB", session: "auto" });
```
When `session` is `"auto"` or `"primary-first"`, EmDash uses the D1 Sessions API to route read queries to nearby replicas. Authenticated users get bookmark-based read-your-writes consistency. See [Database Options — Read Replicas](/deployment/database/#read-replicas) for details.
<Aside type="caution">
D1 requires migrations via Wrangler CLI. DDL statements are not allowed at runtime.
</Aside>
## Storage Adapters
Import from `emdash/astro`:
```js
import emdash, { local, r2, s3 } from "emdash/astro";
```
### `local(config)`
Local filesystem storage.
| Option | Type | Description |
| ----------- | -------- | -------------------------- |
| `directory` | `string` | Directory path |
| `baseUrl` | `string` | Base URL for serving files |
```js
local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
```
### `r2(config)`
Cloudflare R2 binding.
| Option | Type | Description |
| ----------- | -------- | ------------------- |
| `binding` | `string` | R2 binding name |
| `publicUrl` | `string` | Optional public URL |
```js
r2({
binding: "MEDIA",
publicUrl: "https://pub-xxxx.r2.dev",
});
```
### `s3(config?)`
S3-compatible storage. All config fields are optional: any field omitted from
`s3({...})` is resolved from the matching `S3_*` environment variable when the
Node process starts. Explicit values always take precedence.
**Prerequisite:** install `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
in your project. EmDash core does not bundle the AWS SDK. See
[Storage Options → S3-Compatible Storage](/deployment/storage/#s3-compatible-storage)
for details.
| Option | Type | Description |
| ----------------- | -------- | ----------------------------------- |
| `endpoint` | `string` | S3 endpoint URL (`S3_ENDPOINT`) |
| `bucket` | `string` | Bucket name (`S3_BUCKET`) |
| `accessKeyId` | `string` | Access key (`S3_ACCESS_KEY_ID`) |
| `secretAccessKey` | `string` | Secret key (`S3_SECRET_ACCESS_KEY`) |
| `region` | `string` | Region, default `"auto"` (`S3_REGION`) |
| `publicUrl` | `string` | Optional CDN URL (`S3_PUBLIC_URL`) |
```js
// All fields from S3_* environment variables (Node container deployments)
s3()
// Mix: CDN from config, rest from environment
s3({ publicUrl: "https://cdn.example.com" })
// All explicit (unchanged from before)
s3({
endpoint: "https://xxx.r2.cloudflarestorage.com",
bucket: "media",
accessKeyId: process.env.R2_ACCESS_KEY_ID,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
publicUrl: "https://cdn.example.com",
})
```
Runtime environment variable resolution is a Node-only feature. On Cloudflare
Workers, secrets and variables are exposed through the `env` parameter of the
fetch handler, not through `process.env`, so `S3_*` environment variables are
not picked up. Workers deployments should either use the [`r2(config)`](#r2config)
adapter or pass explicit values to `s3({...})`. See
[Storage Options](/deployment/storage/#s3-compatible-storage) for details.
## Live Collections
Configure the EmDash loader in `src/live.config.ts`:
```ts title="src/live.config.ts"
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({
loader: emdashLoader(),
}),
};
```
### Loader Options
The `emdashLoader()` function accepts optional configuration:
```ts
emdashLoader({
// Currently no options - reserved for future use
});
```
## Environment Variables
EmDash respects these environment variables:
| Variable | Description |
| ------------------------- | ---------------------------------------- |
| `EMDASH_SITE_URL` | Public browser-facing origin (falls back to `SITE_URL`) |
| `EMDASH_ALLOWED_ORIGINS` | Comma-separated list of additional origins accepted by passkey verification (multi-subdomain deployments). |
| `EMDASH_DATABASE_URL` | Override database URL |
| `EMDASH_ENCRYPTION_KEY` | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. |
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, a stable per-site value is generated and stored in the database. |
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, a stable per-site value is generated and stored in the database. |
| `EMDASH_AUTH_SECRET` | Legacy. Used as the IP-salt source if set; existing installs should keep this to preserve stable commenter-IP hashes across upgrade. |
| `EMDASH_URL` | Remote EmDash URL for schema sync |
Generate an encryption key with:
```bash
npx emdash secrets generate
```
## package.json Configuration
Optional configuration in `package.json`:
```json title="package.json"
{
"emdash": {
"label": "My Blog Template",
"description": "A clean, minimal blog template",
"seed": ".emdash/seed.json",
"url": "https://my-site.pages.dev",
"preview": "https://emdash-blog.pages.dev"
}
}
```
| Option | Description |
| ------------- | ---------------------------------- |
| `label` | Template name for display |
| `description` | Template description |
| `seed` | Path to seed JSON file |
| `url` | Remote URL for schema sync |
| `preview` | Demo site URL for template preview |
## TypeScript Configuration
EmDash generates types in `.emdash/types.ts`. Add to your `tsconfig.json`:
```json title="tsconfig.json"
{
"compilerOptions": {
"paths": {
"@emdash-cms/types": ["./.emdash/types.ts"]
}
}
}
```
Generate types with:
```bash
npx emdash types
```

View File

@@ -0,0 +1,428 @@
---
title: Field Types Reference
description: Complete reference for all EmDash field types.
---
import { Aside } from "@astrojs/starlight/components";
EmDash supports 14 field types for defining content schemas. Each type maps to a SQLite column type and provides appropriate admin UI.
## Overview
| Type | SQLite Column | Description |
| -------------- | ------------- | -------------------------- |
| `string` | TEXT | Short text input |
| `text` | TEXT | Multi-line text |
| `number` | REAL | Decimal number |
| `integer` | INTEGER | Whole number |
| `boolean` | INTEGER | True/false |
| `datetime` | TEXT | Date and time |
| `select` | TEXT | Single choice from options |
| `multiSelect` | JSON | Multiple choices |
| `portableText` | JSON | Rich text content |
| `image` | TEXT | Image reference |
| `file` | TEXT | File reference |
| `reference` | TEXT | Reference to another entry |
| `json` | JSON | Arbitrary JSON data |
| `slug` | TEXT | URL-safe identifier |
## Text Types
### `string`
Short, single-line text. Use for titles, names, and short values.
```ts
{
slug: "title",
label: "Title",
type: "string",
required: true,
validation: {
minLength: 1,
maxLength: 200,
},
}
```
**Validation options:**
- `minLength` — Minimum character count
- `maxLength` — Maximum character count
- `pattern` — Regex pattern to match
**Widget options:**
- None specific
### `text`
Multi-line plain text. Use for descriptions, excerpts, and longer plain text.
```ts
{
slug: "excerpt",
label: "Excerpt",
type: "text",
options: {
rows: 3,
},
}
```
**Validation options:**
- `minLength` — Minimum character count
- `maxLength` — Maximum character count
**Widget options:**
- `rows` — Number of rows in textarea (default: 3)
### `slug`
URL-safe identifier. Automatically generated from another field or manually entered.
```ts
{
slug: "slug",
label: "URL Slug",
type: "slug",
required: true,
unique: true,
}
```
Slugs are automatically sanitized: lowercased, spaces replaced with hyphens, special characters removed.
## Number Types
### `number`
Decimal number. Use for prices, ratings, and measurements.
```ts
{
slug: "price",
label: "Price",
type: "number",
required: true,
validation: {
min: 0,
max: 999999.99,
},
}
```
**Validation options:**
- `min` — Minimum value
- `max` — Maximum value
Stored as SQLite REAL (64-bit floating point).
### `integer`
Whole number. Use for quantities, counts, and order values.
```ts
{
slug: "quantity",
label: "Quantity",
type: "integer",
defaultValue: 1,
validation: {
min: 0,
max: 1000,
},
}
```
**Validation options:**
- `min` — Minimum value
- `max` — Maximum value
Stored as SQLite INTEGER.
### `boolean`
True or false. Use for toggles and flags.
```ts
{
slug: "featured",
label: "Featured",
type: "boolean",
defaultValue: false,
}
```
Stored as SQLite INTEGER (0 or 1).
## Date and Time
### `datetime`
Date and time value. Stored in ISO 8601 format.
```ts
{
slug: "publishedAt",
label: "Published At",
type: "datetime",
}
```
**Validation options:**
- `min` — Minimum date (ISO string)
- `max` — Maximum date (ISO string)
**Storage format:** `2025-01-24T12:00:00.000Z`
## Selection Types
### `select`
Single selection from predefined options.
```ts
{
slug: "status",
label: "Status",
type: "select",
required: true,
defaultValue: "draft",
validation: {
options: ["draft", "published", "archived"],
},
}
```
**Validation options:**
- `options` — Array of allowed values (required)
Stored as TEXT containing the selected value.
### `multiSelect`
Multiple selections from predefined options.
```ts
{
slug: "tags",
label: "Tags",
type: "multiSelect",
validation: {
options: ["news", "tutorial", "review", "opinion"],
},
}
```
**Validation options:**
- `options` — Array of allowed values (required)
Stored as JSON array: `["news", "tutorial"]`
## Rich Content
### `portableText`
Rich text content using Portable Text format. Supports headings, lists, links, images, and custom blocks.
```ts
{
slug: "content",
label: "Content",
type: "portableText",
required: true,
}
```
Stored as JSON array of Portable Text blocks:
```json
[
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Hello world" }]
}
]
```
Plugins can add custom block types (embeds, widgets, etc.) to the editor. These appear in the slash command menu and are automatically rendered on the site. See [Creating Plugins — Portable Text Block Types](/plugins/creating-plugins/#portable-text-block-types).
<Aside>
Portable Text is a specification for structured rich text. See
[portabletext.org](https://portabletext.org) for details.
</Aside>
## Media Types
### `image`
Reference to an uploaded image. Includes metadata like dimensions and alt text.
```ts
{
slug: "featuredImage",
label: "Featured Image",
type: "image",
options: {
showPreview: true,
},
}
```
**Widget options:**
- `showPreview` — Show image preview in admin (default: true)
**Stored value:**
```json
{
"id": "01HXK5MZSN...",
"url": "https://cdn.example.com/image.jpg",
"alt": "Description",
"width": 1920,
"height": 1080
}
```
### `file`
Reference to an uploaded file (documents, PDFs, etc.).
```ts
{
slug: "document",
label: "Document",
type: "file",
}
```
**Stored value:**
```json
{
"id": "01HXK5MZSN...",
"url": "https://cdn.example.com/doc.pdf",
"filename": "report.pdf",
"mimeType": "application/pdf",
"size": 102400
}
```
## Relational Types
### `reference`
Reference to another content entry.
```ts
{
slug: "author",
label: "Author",
type: "reference",
required: true,
options: {
collection: "authors",
},
}
```
**Widget options:**
- `collection` — Target collection slug (required)
- `allowMultiple` — Allow multiple references (default: false)
**Single reference stored value:**
```json
"01HXK5MZSN..."
```
**Multiple references stored value:**
```json
["01HXK5MZSN...", "01HXK6NATS..."]
```
## Flexible Types
### `json`
Arbitrary JSON data. Use for complex nested structures, third-party integrations, or data without a fixed schema.
```ts
{
slug: "metadata",
label: "Metadata",
type: "json",
}
```
Stored as-is in SQLite JSON column.
<Aside type="caution">JSON fields have no validation. Use sparingly for truly dynamic data.</Aside>
## Field Properties
All fields support these common properties:
| Property | Type | Description |
| -------------- | ----------- | ----------------------------------- |
| `slug` | `string` | Unique identifier (required) |
| `label` | `string` | Display name (required) |
| `type` | `FieldType` | Field type (required) |
| `required` | `boolean` | Require a value (default: false) |
| `unique` | `boolean` | Enforce uniqueness (default: false) |
| `defaultValue` | `unknown` | Default value for new entries |
| `validation` | `object` | Type-specific validation rules |
| `widget` | `string` | Custom widget override |
| `options` | `object` | Widget configuration |
| `sortOrder` | `number` | Display order in admin |
## Reserved Field Slugs
These slugs are reserved and cannot be used:
- `id`
- `slug`
- `status`
- `author_id`
- `created_at`
- `updated_at`
- `published_at`
- `deleted_at`
- `version`
## TypeScript Types
Import field types for programmatic use:
```ts
import type { FieldType, Field, CreateFieldInput } from "emdash";
const fieldTypes: FieldType[] = [
"string",
"text",
"number",
"integer",
"boolean",
"datetime",
"select",
"multiSelect",
"portableText",
"image",
"file",
"reference",
"json",
"slug",
];
```

View File

@@ -0,0 +1,714 @@
---
title: Hook Reference
description: Plugin hooks for extending EmDash functionality.
---
import { Aside } from "@astrojs/starlight/components";
Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
## Hook Overview
| Hook | Trigger | Can Modify | Exclusive |
| ----------------------- | ------------------------------------- | ----------------- | --------- |
| `content:beforeSave` | Before content is saved | Content data | No |
| `content:afterSave` | After content is saved | Nothing | No |
| `content:beforeDelete` | Before content is deleted | Can cancel | No |
| `content:afterDelete` | After content is deleted | Nothing | No |
| `media:beforeUpload` | Before file is uploaded | File metadata | No |
| `media:afterUpload` | After file is uploaded | Nothing | No |
| `cron` | Scheduled task fires | Nothing | No |
| `email:beforeSend` | Before email delivery | Message, can cancel | No |
| `email:deliver` | Deliver email via transport | Nothing | Yes |
| `email:afterSend` | After successful email delivery | Nothing | No |
| `comment:beforeCreate` | Before comment is stored | Comment, can cancel | No |
| `comment:moderate` | Decide comment approval status | Status | Yes |
| `comment:afterCreate` | After comment is stored | Nothing | No |
| `comment:afterModerate` | After admin changes comment status | Nothing | No |
| `page:metadata` | Rendering public page head | Contribute tags | No |
| `page:fragments` | Rendering public page body | Inject scripts | No |
| `plugin:install` | When plugin is first installed | Nothing | No |
| `plugin:activate` | When plugin is enabled | Nothing | No |
| `plugin:deactivate` | When plugin is disabled | Nothing | No |
| `plugin:uninstall` | When plugin is removed | Nothing | No |
## Content Hooks
### `content:beforeSave`
Runs before content is saved to the database. Use to validate, transform, or enrich content.
```ts
import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
hooks: {
"content:beforeSave": async (event, ctx) => {
const { content, collection, isNew } = event;
// Add timestamps
if (isNew) {
content.createdBy = "system";
}
content.modifiedAt = new Date().toISOString();
// Return modified content
return content;
},
},
});
```
#### Event
```ts
interface ContentHookEvent {
content: Record<string, unknown>; // Content data
collection: string; // Collection slug
isNew: boolean; // True for creates, false for updates
}
```
#### Return Value
- Return modified content object to apply changes
- Return `void` to pass through unchanged
### `content:afterSave`
Runs after content is saved. Use for side effects like notifications, cache invalidation, or external syncing.
```ts
hooks: {
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
if (collection === "posts" && content.status === "published") {
// Notify external service
await ctx.http?.fetch("https://api.example.com/notify", {
method: "POST",
body: JSON.stringify({ postId: content.id }),
});
}
},
}
```
#### Return Value
No return value expected.
### `content:beforeDelete`
Runs before content is deleted. Use to validate deletion or prevent it.
```ts
hooks: {
"content:beforeDelete": async (event, ctx) => {
const { id, collection } = event;
// Prevent deletion of protected content
const item = await ctx.content?.get(collection, id);
if (item?.data.protected) {
return false; // Cancel deletion
}
// Allow deletion
return true;
},
}
```
#### Event
```ts
interface ContentDeleteEvent {
id: string; // Entry ID
collection: string; // Collection slug
}
```
#### Return Value
- Return `false` to cancel deletion
- Return `true` or `void` to allow
### `content:afterDelete`
Runs after content is deleted. Use for cleanup tasks.
```ts
hooks: {
"content:afterDelete": async (event, ctx) => {
const { id, collection } = event;
// Clean up related data
await ctx.storage.relatedItems.delete(`${collection}:${id}`);
},
}
```
## Media Hooks
### `media:beforeUpload`
Runs before a file is uploaded. Use to validate, rename, or reject files.
```ts
hooks: {
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
// Reject files over 10MB
if (file.size > 10 * 1024 * 1024) {
throw new Error("File too large");
}
// Rename file
return {
name: `${Date.now()}-${file.name}`,
type: file.type,
size: file.size,
};
},
}
```
#### Event
```ts
interface MediaUploadEvent {
file: {
name: string; // Original filename
type: string; // MIME type
size: number; // Size in bytes
};
}
```
#### Return Value
- Return modified file metadata to apply changes
- Return `void` to pass through unchanged
- Throw to reject the upload
### `media:afterUpload`
Runs after a file is uploaded. Use for processing, thumbnails, or metadata extraction.
```ts
hooks: {
"media:afterUpload": async (event, ctx) => {
const { media } = event;
if (media.mimeType.startsWith("image/")) {
// Store image metadata
await ctx.kv.set(`media:${media.id}:analyzed`, {
processedAt: new Date().toISOString(),
});
}
},
}
```
#### Event
```ts
interface MediaAfterUploadEvent {
media: {
id: string;
filename: string;
mimeType: string;
size: number | null;
url: string;
createdAt: string;
};
}
```
## Lifecycle Hooks
### `plugin:install`
Runs when a plugin is first installed. Use for initial setup, creating storage collections, or seeding data.
```ts
hooks: {
"plugin:install": async (event, ctx) => {
// Initialize default settings
await ctx.kv.set("settings:enabled", true);
await ctx.kv.set("settings:threshold", 100);
ctx.log.info("Plugin installed successfully");
},
}
```
### `plugin:activate`
Runs when a plugin is enabled (after install or re-enable).
```ts
hooks: {
"plugin:activate": async (event, ctx) => {
ctx.log.info("Plugin activated");
},
}
```
### `plugin:deactivate`
Runs when a plugin is disabled.
```ts
hooks: {
"plugin:deactivate": async (event, ctx) => {
ctx.log.info("Plugin deactivated");
},
}
```
### `plugin:uninstall`
Runs when a plugin is removed. Use for cleanup.
```ts
hooks: {
"plugin:uninstall": async (event, ctx) => {
const { deleteData } = event;
if (deleteData) {
// Clean up all plugin data
const items = await ctx.kv.list("settings:");
for (const { key } of items) {
await ctx.kv.delete(key);
}
}
ctx.log.info("Plugin uninstalled");
},
}
```
#### Event
```ts
interface UninstallEvent {
deleteData: boolean; // User chose to delete data
}
```
## Cron Hook
### `cron`
Fired when a scheduled task executes. Schedule tasks with `ctx.cron.schedule()`.
```ts
hooks: {
"cron": async (event, ctx) => {
if (event.name === "daily-sync") {
const data = await ctx.http?.fetch("https://api.example.com/data");
ctx.log.info("Sync complete");
}
},
}
```
#### Event
```ts
interface CronEvent {
name: string;
data?: Record<string, unknown>;
scheduledAt: string;
}
```
## Email Hooks
Email hooks form a pipeline: `email:beforeSend` → `email:deliver` → `email:afterSend`.
### `email:beforeSend`
**Capability:** `hooks.email-events:register`
Middleware hook that runs before delivery. Transform messages or cancel delivery.
```ts
hooks: {
"email:beforeSend": async (event, ctx) => {
// Add footer to all emails
return {
...event.message,
text: event.message.text + "\n\n—Sent from My Site",
};
// Or return false to cancel delivery
},
}
```
#### Event
```ts
interface EmailBeforeSendEvent {
message: { to: string; subject: string; text: string; html?: string };
source: string;
}
```
#### Return Value
- Return modified message to transform
- Return `false` to cancel delivery
- Return `void` to pass through unchanged
### `email:deliver`
**Capability:** `hooks.email-transport:register` | **Exclusive:** Yes
The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.
```ts
hooks: {
"email:deliver": {
exclusive: true,
handler: async (event, ctx) => {
await sendViaSES(event.message);
},
},
}
```
### `email:afterSend`
**Capability:** `hooks.email-events:register`
Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.
```ts
hooks: {
"email:afterSend": async (event, ctx) => {
await ctx.kv.set(`email:log:${Date.now()}`, {
to: event.message.to,
subject: event.message.subject,
});
},
}
```
## Comment Hooks
Comment hooks form a pipeline: `comment:beforeCreate` → `comment:moderate` → `comment:afterCreate`. The `comment:afterModerate` hook fires separately when an admin changes a comment's status.
### `comment:beforeCreate`
**Capability:** `users:read`
Middleware hook before a comment is stored. Enrich, validate, or reject comments.
```ts
hooks: {
"comment:beforeCreate": async (event, ctx) => {
// Reject comments with links
if (event.comment.body.includes("http")) {
return false;
}
},
}
```
#### Event
```ts
interface CommentBeforeCreateEvent {
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
};
metadata: Record<string, unknown>;
}
```
#### Return Value
- Return modified event to transform
- Return `false` to reject
- Return `void` to pass through
### `comment:moderate`
**Capability:** `users:read` | **Exclusive:** Yes
Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.
```ts
hooks: {
"comment:moderate": {
exclusive: true,
handler: async (event, ctx) => {
const score = await checkSpam(event.comment);
return {
status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved",
reason: `Spam score: ${score}`,
};
},
},
}
```
#### Event
```ts
interface CommentModerateEvent {
comment: { /* same as beforeCreate */ };
metadata: Record<string, unknown>;
collectionSettings: {
commentsEnabled: boolean;
commentsModeration: "all" | "first_time" | "none";
commentsClosedAfterDays: number;
commentsAutoApproveUsers: boolean;
};
priorApprovedCount: number;
}
```
#### Return Value
```ts
{ status: "approved" | "pending" | "spam"; reason?: string }
```
### `comment:afterCreate`
**Capability:** `users:read`
Fire-and-forget hook after a comment is stored. Use for notifications.
```ts
hooks: {
"comment:afterCreate": async (event, ctx) => {
if (event.comment.status === "approved") {
await ctx.email?.send({
to: event.contentAuthor?.email,
subject: `New comment on "${event.content.title}"`,
text: `${event.comment.authorName} commented: ${event.comment.body}`,
});
}
},
}
```
### `comment:afterModerate`
**Capability:** `users:read`
Fire-and-forget hook when an admin manually changes a comment's status.
#### Event
```ts
interface CommentAfterModerateEvent {
comment: { id: string; /* ... */ };
previousStatus: string;
newStatus: string;
moderator: { id: string; name: string | null };
}
```
## Page Hooks
Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
### `page:metadata`
**Capability:** None required
Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.
```ts
hooks: {
"page:metadata": async (event, ctx) => {
return [
{ kind: "meta", name: "generator", content: "EmDash" },
{ kind: "property", property: "og:site_name", content: event.page.siteName },
{ kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } },
];
},
}
```
#### Contribution Types
```ts
type PageMetadataContribution =
| { kind: "meta"; name: string; content: string; key?: string }
| { kind: "property"; property: string; content: string; key?: string }
| { kind: "link"; rel: string; href: string; hreflang?: string; key?: string }
| { kind: "jsonld"; id?: string; graph: Record<string, unknown> };
```
The `key` field deduplicates contributions — only the last contribution with a given key is used.
### `page:fragments`
**Capability:** `hooks.page-fragments:register`
Inject scripts or HTML into pages. Only available to native plugins.
```ts
hooks: {
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "body:end",
src: "https://analytics.example.com/script.js",
async: true,
},
{
kind: "inline-script",
placement: "head",
code: `window.siteId = "abc123";`,
},
];
},
}
```
#### Contribution Types
```ts
type PageFragmentContribution =
| {
kind: "external-script";
placement: "head" | "body:start" | "body:end";
src: string;
async?: boolean;
defer?: boolean;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "inline-script";
placement: "head" | "body:start" | "body:end";
code: string;
attributes?: Record<string, string>;
key?: string;
}
| {
kind: "html";
placement: "head" | "body:start" | "body:end";
html: string;
key?: string;
};
```
## Hook Configuration
Hooks accept either a handler function or a configuration object:
```ts
hooks: {
// Simple handler
"content:afterSave": async (event, ctx) => { ... },
// With configuration
"content:beforeSave": {
priority: 50, // Lower runs first (default: 100)
timeout: 10000, // Max execution time in ms (default: 5000)
dependencies: [], // Run after these plugins
errorPolicy: "abort", // "continue" or "abort" (default)
handler: async (event, ctx) => { ... },
},
}
```
### Configuration Options
| Option | Type | Default | Description |
| -------------- | ---------- | --------- | ---------------------------------------------------- |
| `priority` | `number` | `100` | Execution order (lower = earlier) |
| `timeout` | `number` | `5000` | Max execution time in milliseconds |
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run first |
| `errorPolicy` | `string` | `"abort"` | `"continue"` to ignore errors |
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider (for provider-pattern hooks like `email:deliver`, `comment:moderate`) |
## Plugin Context
All hooks receive a context object with access to plugin APIs:
```ts
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
content?: ContentAccess;
media?: MediaAccess;
http?: HttpAccess;
log: LogAccess;
site: { name: string; url: string; locale: string };
url(path: string): string;
users?: UserAccess;
cron?: CronAccess;
email?: EmailAccess;
}
```
See [Plugin Overview — Plugin Context](/plugins/overview/#plugin-context) for capability requirements and method details.
<Aside>
Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin
definition.
</Aside>
## Error Handling
Errors in hooks are logged and handled based on `errorPolicy`:
- `"abort"` (default) — Stop execution, rollback transaction if applicable
- `"continue"` — Log error and continue to next hook
```ts
hooks: {
"content:beforeSave": {
errorPolicy: "continue", // Don't block save if this fails
handler: async (event, ctx) => {
try {
await ctx.http?.fetch("https://api.example.com/validate");
} catch (error) {
ctx.log.warn("Validation service unavailable", error);
}
},
},
}
```
## Execution Order
Hooks run in this order:
1. Sorted by `priority` (ascending)
2. Plugins with `dependencies` run after their dependencies
3. Within same priority, order is deterministic but unspecified
```ts
// This runs first (priority 10)
{ priority: 10, handler: ... }
// This runs second (priority 50)
{ priority: 50, handler: ... }
// This runs last (default priority 100)
{ handler: ... }
```

View File

@@ -0,0 +1,742 @@
---
title: MCP Server Reference
description: Protocol details, tool specifications, and OAuth configuration for the MCP server.
---
import { Aside } from "@astrojs/starlight/components";
EmDash includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server at `/_emdash/api/mcp` that exposes content management operations as tools for AI assistants.
<Aside>
Looking to connect Claude, ChatGPT, or another AI tool to your site? See the [AI Tools guide](/guides/ai-tools) for setup instructions and usage tips.
</Aside>
This page covers the protocol details: authentication, transport, tool specifications, OAuth discovery, and error handling.
## Authentication
The MCP server supports three authentication methods:
| Method | How it works |
| --- | --- |
| **OAuth 2.1 Authorization Code + PKCE** | Standard flow for MCP clients. User approves scopes in the browser. |
| **Personal Access Token (PAT)** | Long-lived `ec_pat_*` tokens created in the admin panel. |
| **Device Flow** | CLI-style flow where you approve a code in the browser. Used by `emdash login`. |
Session cookies (from the admin UI) also work but aren't practical for external MCP clients.
### Scopes
Tokens are scoped to limit what operations a client can perform. Scopes are requested during OAuth authorization and enforced on every tool call.
| Scope | Grants access to |
| --- | --- |
| `content:read` | List, get, compare, and search content. List taxonomies, taxonomy terms, and menus. |
| `content:write` | Create, update, delete, publish, unpublish, schedule, unschedule, duplicate, and restore content. Implicitly grants `taxonomies:manage` and `menus:manage` for backwards compatibility with tokens issued before those scopes existed. |
| `media:read` | List and get media items. |
| `media:write` | Register (create), update, and delete media metadata. |
| `schema:read` | List collections and get collection schemas. |
| `schema:write` | Create and delete collections and fields. |
| `taxonomies:manage` | Create, update, and delete taxonomy terms. |
| `menus:manage` | Create, update, and delete navigation menus and their items. |
| `settings:read` | Read site-wide settings. |
| `settings:manage` | Update site-wide settings. |
| `admin` | Full access to all operations. |
The `admin` scope grants access to everything. Session-based auth (no token) also has full access based on the user's role.
`content:write` implicitly grants `taxonomies:manage` and `menus:manage` so personal access tokens issued before those scopes were split out continue to work without re-issue. New tokens should request the granular scopes.
### Role Requirements
In addition to scopes, some tools require a minimum RBAC role. Both must be satisfied -- a token with the right scope still fails if the calling user's role is too low.
| Operation | Minimum role |
| --- | --- |
| Content read | Subscriber (10) for published items; Contributor (20) for drafts, scheduled, trash, and revisions |
| Content create / edit own / delete own | Author (30) |
| Content publish | Author (30) for own items; Editor (40) to act on others' items |
| Schema read | Editor (40) |
| Schema write | Admin (50) |
| Taxonomies manage | Editor (40) |
| Menus manage | Editor (40) |
| Settings read | Editor (40) |
| Settings manage | Admin (50) |
| Media upload (`media_create`) | Author (30) |
See the [Authentication guide](/guides/authentication#user-roles) for role definitions.
## Transport
The server uses the Streamable HTTP transport in **stateless mode**. Each request is independent -- there are no sessions or long-lived connections.
- **`POST /_emdash/api/mcp`** -- Send JSON-RPC tool calls
- **`GET /_emdash/api/mcp`** -- Returns 405 (no SSE in stateless mode)
- **`DELETE /_emdash/api/mcp`** -- Returns 405 (no session to close)
Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) format. Errors use standard JSON-RPC error codes, with MCP-specific codes for scope and permission failures.
## Tools
The server exposes 43 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.
### Content Tools
#### `content_list`
List content items in a collection with optional filtering and pagination.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug (e.g. `posts`, `pages`) |
| `status` | `string` | No | Filter: `draft`, `published`, or `scheduled` |
| `limit` | `integer` | No | Max items to return (1-100, default 50) |
| `cursor` | `string` | No | Pagination cursor from a previous response |
| `orderBy` | `string` | No | Field to sort by (e.g. `created_at`, `updated_at`) |
| `order` | `string` | No | Sort direction: `asc` or `desc` (default `desc`) |
| `locale` | `string` | No | Filter by locale (e.g. `en`, `fr`). Only relevant with i18n. |
**Scope:** `content:read` | **Read-only:** Yes
#### `content_get`
Get a single content item by ID or slug. Returns all field values, metadata, and a `_rev` token for optimistic concurrency.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID (ULID) or slug |
| `locale` | `string` | No | Locale for slug lookup. IDs are globally unique. |
**Scope:** `content:read` | **Read-only:** Yes
<Aside>
The `_rev` token in the response is used for conflict detection. Pass it back when updating to ensure no one else has modified the item since you read it.
</Aside>
#### `content_create`
Create a new content item. The `data` object should contain field values matching the collection's schema -- use `schema_get_collection` to check what fields are available. Items are created as `draft` by default.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `data` | `object` | Yes | Field values as key-value pairs |
| `slug` | `string` | No | URL slug (auto-generated from title if omitted) |
| `status` | `string` | No | Initial status: `draft` or `published` (default `draft`) |
| `locale` | `string` | No | Locale for this content (defaults to site default) |
| `translationOf` | `string` | No | ID of the item this is a translation of |
**Scope:** `content:write`
#### `content_update`
Update an existing content item. Only include fields you want to change -- unspecified fields are left unchanged.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
| `data` | `object` | No | Field values to update |
| `slug` | `string` | No | New URL slug |
| `status` | `string` | No | New status: `draft` or `published` |
| `_rev` | `string` | No | Revision token from `content_get` for conflict detection |
**Scope:** `content:write`
#### `content_delete`
Soft-delete a content item by moving it to the trash. Use `content_restore` to undo, or `content_permanent_delete` to remove it forever.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write` | **Destructive:** Yes
#### `content_restore`
Restore a soft-deleted content item from the trash.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write`
#### `content_permanent_delete`
Permanently and irreversibly delete a trashed content item. The item must be in the trash first.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write` | **Destructive:** Yes
#### `content_publish`
Publish a content item, making it live on the site. Creates a published revision from the current draft. Further edits create a new draft without affecting the live version until re-published.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write`
#### `content_unpublish`
Revert a published item to draft status. It will no longer be visible on the live site but its content is preserved.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write`
#### `content_schedule`
Schedule a content item for future publication. It will be automatically published at the specified date/time.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
| `scheduledAt` | `string` | Yes | ISO 8601 datetime (e.g. `2026-06-01T09:00:00Z`) |
**Scope:** `content:write`
#### `content_unschedule`
Cancel a previously scheduled publication. The item keeps its current status; only the `scheduledAt` timestamp is cleared. Idempotent -- calling on an item that isn't scheduled is a no-op.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write`
#### `content_compare`
Compare the published (live) version of a content item with its current draft. Returns both versions and a flag indicating whether there are changes.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:read` | **Read-only:** Yes
#### `content_discard_draft`
Discard the current draft and revert to the last published version. Only works on items that have been published at least once.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:write` | **Destructive:** Yes
#### `content_list_trashed`
List soft-deleted content items in a collection's trash.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `limit` | `integer` | No | Max items (1-100, default 50) |
| `cursor` | `string` | No | Pagination cursor |
**Scope:** `content:read` | **Read-only:** Yes
#### `content_duplicate`
Create a copy of an existing content item. The duplicate is created as a draft with "(Copy)" appended to the title and an auto-generated slug.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug to duplicate |
**Scope:** `content:write`
#### `content_translations`
Get all locale variants of a content item. Returns the translation group and a summary of each locale version. Only relevant when i18n is enabled.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
**Scope:** `content:read` | **Read-only:** Yes
### Schema Tools
<Aside type="caution">
Schema tools modify the database structure. Creating or deleting collections and fields changes the underlying tables. These operations require Admin role.
</Aside>
#### `schema_list_collections`
List all content collections defined in the CMS. Returns slug, label, supported features, and timestamps.
**No parameters.**
**Scope:** `schema:read` | **Minimum role:** Editor | **Read-only:** Yes
#### `schema_get_collection`
Get detailed info about a collection including all field definitions. Fields describe the data model: name, type, constraints, and validation rules. Use this to understand what `content_create` and `content_update` expect.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | `string` | Yes | Collection slug (e.g. `posts`) |
**Scope:** `schema:read` | **Minimum role:** Editor | **Read-only:** Yes
#### `schema_create_collection`
Create a new content collection. This creates a database table and schema definition. The slug must be lowercase alphanumeric with underscores, starting with a letter.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | `string` | Yes | Unique identifier (`/^[a-z][a-z0-9_]*$/`) |
| `label` | `string` | Yes | Display name (plural, e.g. "Blog Posts") |
| `labelSingular` | `string` | No | Singular display name |
| `description` | `string` | No | Description of this collection |
| `icon` | `string` | No | Icon name for the admin UI |
| `supports` | `string[]` | No | Features: `drafts`, `revisions`, `preview`, `scheduling`, `search` (default: `['drafts', 'revisions']`) |
**Scope:** `schema:write` | **Minimum role:** Admin
#### `schema_delete_collection`
Delete a collection and its database table. This is irreversible and deletes all content in the collection.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `slug` | `string` | Yes | Collection slug to delete |
| `force` | `boolean` | No | Force deletion even if the collection has content |
**Scope:** `schema:write` | **Minimum role:** Admin | **Destructive:** Yes
#### `schema_create_field`
Add a new field to a collection's schema. This adds a column to the database table.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `slug` | `string` | Yes | Field identifier (`/^[a-z][a-z0-9_]*$/`) |
| `label` | `string` | Yes | Display name |
| `type` | `string` | Yes | Data type (see below) |
| `required` | `boolean` | No | Whether the field is required |
| `unique` | `boolean` | No | Whether values must be unique |
| `defaultValue` | `any` | No | Default value for new items |
| `validation` | `object` | No | Constraints: `min`, `max`, `minLength`, `maxLength`, `pattern`, `options` |
| `options` | `object` | No | Widget config: `collection` (for references), `rows` (for textarea) |
| `searchable` | `boolean` | No | Include in full-text search index |
| `translatable` | `boolean` | No | Whether this field is translatable (default true) |
Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, `reference`, `json`, `slug`.
For `select` and `multiSelect` types, provide allowed values in `validation.options`.
**Scope:** `schema:write` | **Minimum role:** Admin
#### `schema_delete_field`
Remove a field from a collection. This drops the column and deletes all data in that field. Irreversible.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `fieldSlug` | `string` | Yes | Field slug to remove |
**Scope:** `schema:write` | **Minimum role:** Admin | **Destructive:** Yes
### Media Tools
#### `media_list`
List uploaded media files with optional MIME type filtering and pagination.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `mimeType` | `string` | No | Filter by MIME type prefix (e.g. `image/`, `application/pdf`) |
| `limit` | `integer` | No | Max items (1-100, default 50) |
| `cursor` | `string` | No | Pagination cursor |
**Scope:** `media:read` | **Read-only:** Yes
#### `media_create`
Register a media file that has already been uploaded to storage. The caller is responsible for placing the file at `storageKey` (typically using a signed upload URL from the admin UI or a separate API). This tool persists the metadata record so the file is discoverable via `media_list` / `media_get` and can be referenced by content.
<Aside>
The MCP transport is not appropriate for binary uploads. Use the signed-upload flow to put the bytes in storage, then call `media_create` to register the record.
</Aside>
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `filename` | `string` | Yes | Original filename (e.g. `logo.png`) |
| `mimeType` | `string` | Yes | MIME type (e.g. `image/png`) |
| `storageKey` | `string` | Yes | Storage path/key the file was uploaded to |
| `size` | `integer` | No | File size in bytes |
| `width` | `integer` | No | Image width in pixels |
| `height` | `integer` | No | Image height in pixels |
| `contentHash` | `string` | No | Hash of the file contents (for dedupe) |
| `blurhash` | `string` | No | Blurhash for image placeholders |
| `dominantColor` | `string` | No | Hex color string for the image's dominant color |
**Scope:** `media:write` | **Minimum role:** Author
#### `media_get`
Get details of a single media file by ID. Returns metadata including filename, MIME type, size, dimensions, alt text, and URL.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Media item ID |
**Scope:** `media:read` | **Read-only:** Yes
#### `media_update`
Update metadata of an uploaded media file. The file itself cannot be changed.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Media item ID |
| `alt` | `string` | No | Alt text for accessibility |
| `caption` | `string` | No | Caption text |
| `width` | `integer` | No | Image width in pixels |
| `height` | `integer` | No | Image height in pixels |
**Scope:** `media:write`
#### `media_delete`
Permanently delete a media file. Removes the database record and the file from storage. Content referencing this media will have broken references.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | `string` | Yes | Media item ID |
**Scope:** `media:write` | **Destructive:** Yes
### Search Tool
#### `search`
Full-text search across content collections. Collections must have `search` in their `supports` list and fields must be marked as `searchable`.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | `string` | Yes | Search query text |
| `collections` | `string[]` | No | Limit search to specific collection slugs |
| `locale` | `string` | No | Filter results by locale |
| `limit` | `integer` | No | Max results (1-50, default 20) |
**Scope:** `content:read` | **Read-only:** Yes
### Taxonomy Tools
#### `taxonomy_list`
List all taxonomy definitions (e.g. categories, tags). Returns name, label, whether hierarchical, and associated collections.
**No parameters.**
**Scope:** `content:read` | **Read-only:** Yes
#### `taxonomy_list_terms`
List terms in a taxonomy with pagination.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `taxonomy` | `string` | Yes | Taxonomy name (e.g. `categories`, `tags`) |
| `limit` | `integer` | No | Max items (1-100, default 50) |
| `cursor` | `string` | No | Pagination cursor |
**Scope:** `content:read` | **Read-only:** Yes
#### `taxonomy_create_term`
Create a new term in a taxonomy. For hierarchical taxonomies, specify a `parentId` to create a child term. The parent's ancestor chain must not exceed 100 levels.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `taxonomy` | `string` | Yes | Taxonomy name |
| `slug` | `string` | Yes | URL-safe identifier |
| `label` | `string` | Yes | Display name |
| `parentId` | `string` | No | Parent term ID (for hierarchical taxonomies) |
| `description` | `string` | No | Description of the term |
**Scope:** `taxonomies:manage` | **Minimum role:** Editor
#### `taxonomy_update_term`
Update an existing term in a taxonomy. Any field can be omitted to leave it unchanged. Renaming a slug must not collide with another term in the same taxonomy. Set `parentId` to `null` to detach from a parent. The new parent must exist, belong to the same taxonomy, and not introduce a cycle.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `taxonomy` | `string` | Yes | Taxonomy name |
| `termSlug` | `string` | Yes | Current slug of the term to update |
| `slug` | `string` | No | New slug (must be unique in the taxonomy) |
| `label` | `string` | No | New display name |
| `parentId` | `string \| null` | No | New parent term ID; `null` to detach |
| `description` | `string` | No | New description |
**Scope:** `taxonomies:manage` | **Minimum role:** Editor
#### `taxonomy_delete_term`
Permanently delete a term from a taxonomy. Any content tagged with the term loses the association. Cannot delete a term that has children -- delete children first.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `taxonomy` | `string` | Yes | Taxonomy name |
| `termSlug` | `string` | Yes | Slug of the term to delete |
**Scope:** `taxonomies:manage` | **Minimum role:** Editor | **Destructive:** Yes
### Menu Tools
#### `menu_list`
List all navigation menus. Returns name, label, and timestamps.
**No parameters.**
**Scope:** `content:read` | **Read-only:** Yes
#### `menu_get`
Get a menu by name including all its items in order. Items have a label, URL, type, and optional parent for nesting.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name (e.g. `main`, `footer`) |
**Scope:** `content:read` | **Read-only:** Yes
#### `menu_create`
Create a new navigation menu. The `name` is the stable identifier used by site templates; `label` is the human-readable name shown in the admin. Add items afterwards with `menu_set_items`.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Stable identifier (`/^[a-z][a-z0-9_]*$/`) |
| `label` | `string` | Yes | Display name for the admin |
**Scope:** `menus:manage` | **Minimum role:** Editor
#### `menu_update`
Update a menu's label. The `name` (stable identifier) cannot be changed.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name to update |
| `label` | `string` | Yes | New display label |
**Scope:** `menus:manage` | **Minimum role:** Editor
#### `menu_delete`
Delete a menu and all its items. Cannot be undone.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name to delete |
**Scope:** `menus:manage` | **Minimum role:** Editor | **Destructive:** Yes
#### `menu_set_items`
Replace the entire item list of a menu in one call. Atomic: existing items are deleted and the new list is inserted in the order provided. Use this rather than per-item add/remove operations so the resulting order and parent links are unambiguous.
Items are positioned by array index. Nesting is expressed via `parentIndex` -- an item with `parentIndex: 0` is nested under the item at index `0`. The parent must appear earlier in the list. Items without `parentIndex` are top-level.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` | Yes | Menu name to update |
| `items` | `MenuItem[]` | Yes | Ordered list of menu items (see below) |
Each `MenuItem` has:
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `label` | `string` | Yes | Item display text |
| `type` | `string` | Yes | One of `custom`, `page`, `post`, `taxonomy`, `collection` |
| `customUrl` | `string` | No | URL for `type: "custom"` items (ignored otherwise) |
| `referenceCollection` | `string` | No | Target collection slug for content references |
| `referenceId` | `string` | No | Target content / term ID for references |
| `titleAttr` | `string` | No | HTML `title` attribute |
| `target` | `string` | No | HTML `target` attribute (e.g. `_blank`) |
| `cssClasses` | `string` | No | Space-separated CSS classes |
| `parentIndex` | `integer` | No | Array index of the parent item. Omit for top-level items. |
**Scope:** `menus:manage` | **Minimum role:** Editor
### Revision Tools
#### `revision_list`
List revision history for a content item, newest first. Requires the collection to support `revisions`.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `collection` | `string` | Yes | Collection slug |
| `id` | `string` | Yes | Content item ID or slug |
| `limit` | `integer` | No | Max revisions (1-50, default 20) |
**Scope:** `content:read` | **Read-only:** Yes
#### `revision_restore`
Restore a content item to a previous revision. Replaces the current draft with the specified revision's data. Not automatically published -- use `content_publish` afterward if needed.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `revisionId` | `string` | Yes | Revision ID to restore |
**Scope:** `content:write`
### Settings Tools
Site-wide settings -- title, tagline, logo, favicon, canonical URL, default page size, date and time formatting, social handles, and SEO defaults.
#### `settings_get`
Get all site-wide settings. Media references (`logo`, `favicon`, `seo.defaultOgImage`) include resolved URLs alongside the underlying `mediaId`. Unset values are omitted from the response.
**No parameters.**
**Scope:** `settings:read` | **Minimum role:** Editor | **Read-only:** Yes
#### `settings_update`
Update one or more site-wide settings. Partial update: only the fields provided are changed; omitted fields are left as-is. Returns the full settings object after the update.
To set a media reference (`logo`, `favicon`, `seo.defaultOgImage`), pass an object with `mediaId` (and optional `alt`). The media item must already exist -- use `media_create` first.
| Parameter | Type | Required | Description |
| --- | --- | --- | --- |
| `title` | `string` | No | Site title |
| `tagline` | `string` | No | Short description shown alongside the title |
| `logo` | `MediaRef` | No | Logo media reference (`{ mediaId, alt? }`) |
| `favicon` | `MediaRef` | No | Favicon media reference |
| `url` | `string` | No | Canonical site URL (http or https). Empty string clears it. |
| `postsPerPage` | `integer` | No | Default page size for content listings (1-100) |
| `dateFormat` | `string` | No | Date format token string |
| `timezone` | `string` | No | IANA timezone identifier |
| `social` | `object` | No | Social handles -- `twitter`, `github`, `facebook`, `instagram`, `linkedin`, `youtube` |
| `seo` | `object` | No | SEO defaults (see below) |
The `seo` object accepts:
| Field | Type | Description |
| --- | --- | --- |
| `titleSeparator` | `string` | Separator between page title and site title (e.g. `" \| "` for a vertical bar) |
| `defaultOgImage` | `MediaRef` | Default Open Graph image when content has none |
| `robotsTxt` | `string` | Custom `robots.txt` body. Omit to use the EmDash default. |
| `googleVerification` | `string` | Google Search Console verification token |
| `bingVerification` | `string` | Bing Webmaster Tools verification token |
**Scope:** `settings:manage` | **Minimum role:** Admin
## OAuth Discovery
MCP clients that support OAuth 2.1 can automatically discover how to authenticate. The server publishes two metadata documents:
### Protected Resource Metadata
```http
GET /.well-known/oauth-protected-resource
```
```json
{
"resource": "https://example.com/_emdash/api/mcp",
"authorization_servers": ["https://example.com/_emdash"],
"scopes_supported": [
"content:read", "content:write",
"media:read", "media:write",
"schema:read", "schema:write",
"taxonomies:manage", "menus:manage",
"settings:read", "settings:manage",
"admin"
],
"bearer_methods_supported": ["header"]
}
```
### Authorization Server Metadata
```http
GET /.well-known/oauth-authorization-server/_emdash
```
```json
{
"issuer": "https://example.com/_emdash",
"authorization_endpoint": "https://example.com/_emdash/oauth/authorize",
"token_endpoint": "https://example.com/_emdash/api/oauth/token",
"scopes_supported": ["content:read", "content:write", "..."],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"device_authorization_endpoint": "https://example.com/_emdash/api/oauth/device/code"
}
```
When an unauthenticated request hits the MCP endpoint, the server returns:
```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
```
This triggers the standard MCP client discovery flow.
## Error Handling
Tool errors are returned as text content with `isError: true`:
```json
{
"content": [{ "type": "text", "text": "Collection 'nonexistent' not found" }],
"isError": true
}
```
Scope and permission errors throw MCP protocol errors:
```json
{
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": "Insufficient scope: requires content:write"
},
"id": 1
}
```
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code `-32603` (Internal error) without leaking implementation details.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,749 @@
---
title: Creating Themes
description: Build and distribute your own EmDash themes.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
An EmDash theme is a complete Astro site -- pages, layouts, components, styles -- that also includes a seed file to bootstrap the content model. Build one to share your design with others, or to standardize site creation for your agency.
## Key Concepts
- **A theme is a working Astro project.** There's no theme API or abstraction layer. You build a site and ship it as a template. The seed file just tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run.
- **EmDash gives you more control over the content model than WordPress.** Themes take advantage of this -- the seed file declares exactly what fields each collection needs. Build on the standard **posts** and **pages** collections and add fields and taxonomies as your design requires, rather than inventing entirely new content types.
- **Theme content pages must be server-rendered.** In a theme, content changes at runtime through the admin UI, so pages that display EmDash content must not be prerendered. Do not use `getStaticPaths()` in theme content routes. (Static site builds using EmDash as a build-time data source _can_ use `getStaticPaths`, but themes are always SSR.)
- **No hard-coded content.** Site title, tagline, navigation, and other dynamic content come from the CMS via API calls -- not from template strings.
## Project Structure
Create a theme with this structure:
```
my-emdash-theme/
├── package.json # Theme metadata
├── astro.config.mjs # Astro + EmDash configuration
├── src/
│ ├── live.config.ts # Live Collections setup
│ ├── pages/
│ │ ├── index.astro # Homepage
│ │ ├── [...slug].astro # Pages (catch-all)
│ │ ├── posts/
│ │ │ ├── index.astro # Post archive
│ │ │ └── [slug].astro # Single post
│ │ ├── categories/
│ │ │ └── [slug].astro # Category archive
│ │ ├── tags/
│ │ │ └── [slug].astro # Tag archive
│ │ ├── search.astro # Search page
│ │ └── 404.astro # Not found
│ ├── layouts/
│ │ └── Base.astro # Base layout
│ └── components/ # Your components
├── .emdash/
│ ├── seed.json # Schema and sample content
│ └── uploads/ # Optional local media files
└── public/ # Static assets
```
Pages live at the root as a catch-all route (`[...slug].astro`), so a page with slug `about` renders at `/about`. Posts, categories, and tags get their own directories. The `.emdash/` directory contains the seed file and any local media files used in sample content.
## Configuring package.json
Add the `emdash` field to your `package.json`:
```json title="package.json"
{
"name": "@your-org/emdash-theme-blog",
"version": "1.0.0",
"description": "A minimal blog theme for EmDash",
"keywords": ["astro-template", "emdash", "blog"],
"emdash": {
"label": "Minimal Blog",
"description": "A clean, minimal blog with posts, pages, and categories",
"seed": ".emdash/seed.json",
"preview": "https://your-theme-demo.pages.dev"
}
}
```
| Field | Description |
| ---------------------- | ----------------------------------- |
| `emdash.label` | Display name shown in theme pickers |
| `emdash.description` | Brief description of the theme |
| `emdash.seed` | Path to the seed file |
| `emdash.preview` | URL to a live demo (optional) |
## The Default Content Model
Most themes need two collection types: **posts** and **pages**. Posts are timestamped entries with excerpts and featured images that appear in feeds and archives. Pages are standalone content at top-level URLs.
This is the recommended starting point. Add more collections, taxonomies, or fields as your theme needs them, but start here.
### Seed File
The seed file tells EmDash what to create on first run. Create `.emdash/seed.json`:
```json title=".emdash/seed.json"
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Minimal Blog",
"description": "A clean blog with posts and pages",
"author": "Your Name"
},
"settings": {
"title": "My Blog",
"tagline": "Thoughts and ideas",
"postsPerPage": 10
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
{ "slug": "featured_image", "label": "Featured Image", "type": "image" }
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" }
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" }
]
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "Blog", "url": "/posts" }
]
}
],
"redirects": [
{ "source": "/category/news", "destination": "/categories/news" },
{ "source": "/old-about", "destination": "/about" }
]
}
```
Posts get `excerpt` and `featured_image` because they appear in lists and feeds. Pages don't need them -- they're standalone content. Add fields to either collection as your theme requires.
See [Seed File Format](/themes/seed-files/) for the complete specification, including sections, widget areas, and media references.
## Building Pages
All pages that display EmDash content are server-rendered. Use `Astro.params` to get the slug from the URL and query content at request time.
<Aside type="caution">
In themes, never use `getStaticPaths()` or `export const prerender = true` for pages that display
EmDash content. Themes serve content at runtime through the admin UI, so these pages must be
server-rendered.
</Aside>
### Homepage
```astro title="src/pages/index.astro"
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", {
where: { status: "published" },
orderBy: { publishedAt: "desc" },
limit: settings.postsPerPage ?? 10,
});
---
<Base title="Home">
<h1>Latest Posts</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</Base>
```
### Single Post
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
const categories = await getEntryTerms("posts", post.id, "categories");
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<div class="post-meta">
{categories.map((cat) => (
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
))}
</div>
</article>
</Base>
```
### Pages
Pages use a catch-all route at the root so their slugs map directly to top-level URLs -- a page with slug `about` renders at `/about`:
```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article>
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
```
Because this is a catch-all route, it only matches URLs that don't have a more specific route. `/posts/hello-world` still hits `posts/[slug].astro`, not this file.
### Category Archive
```astro title="src/pages/categories/[slug].astro"
---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<h1>{category.label}</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
</article>
))}
</Base>
```
## Using Images
Image fields are objects with `src` and `alt` properties, not strings. Use the `Image` component from `emdash/ui` for optimized image rendering:
```astro title="src/components/PostCard.astro"
---
import { Image } from "emdash/ui";
const { post } = Astro.props;
---
<article>
{post.data.featured_image?.src && (
<Image
image={post.data.featured_image}
alt={post.data.featured_image.alt || post.data.title}
width={800}
height={450}
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
```
<Aside type="caution">
A common mistake is treating image fields as strings. `post.data.featured_image` is an object
with `src` and `alt` -- writing `<img src={post.data.featured_image} />` renders `[object Object]`.
</Aside>
## Using Menus
Query admin-defined menus in your layouts. Never hard-code navigation links:
```astro title="src/layouts/Base.astro"
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<html>
<head>
<title>{Astro.props.title} | {settings.title}</title>
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
<nav>
{primaryMenu?.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
```
## Page Templates
Themes often need multiple page layouts -- a default layout, a full-width layout, a landing page layout. In EmDash, add a `template` select field to the pages collection and map it to layout components in your catch-all route.
Add the field to your pages collection in the seed file:
```json
{
"slug": "template",
"label": "Page Template",
"type": "string",
"widget": "select",
"options": {
"choices": [
{ "value": "default", "label": "Default" },
{ "value": "full-width", "label": "Full Width" },
{ "value": "landing", "label": "Landing Page" }
]
},
"defaultValue": "default"
}
```
Then map the value to layout components in the catch-all route:
```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import PageDefault from "../layouts/PageDefault.astro";
import PageFullWidth from "../layouts/PageFullWidth.astro";
import PageLanding from "../layouts/PageLanding.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
const layouts = {
"default": PageDefault,
"full-width": PageFullWidth,
"landing": PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
```
Editors choose the template from a dropdown in the admin UI when editing a page.
## Adding Sections
Sections are reusable content blocks that editors can insert into any Portable Text field using the `/section` slash command. If your theme has common content patterns (hero banners, CTAs, feature grids), define them as sections in the seed file:
```json title=".emdash/seed.json"
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Your compelling tagline goes here." }
]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter Signup",
"keywords": ["newsletter", "subscribe", "email"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
},
{
"_type": "block",
"children": [
{
"_type": "span",
"text": "Get the latest updates delivered to your inbox."
}
]
}
]
}
]
}
```
Sections created from the seed file are marked with `source: "theme"`. Editors can also create their own sections (marked `source: "user"`), but theme-provided sections cannot be deleted from the admin UI.
## Adding Sample Content
Include sample content in the seed file to demonstrate your theme's design:
```json title=".emdash/seed.json"
{
"content": {
"posts": [
{
"id": "hello-world",
"slug": "hello-world",
"status": "published",
"data": {
"title": "Hello World",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
}
],
"excerpt": "Your first post on EmDash."
},
"taxonomies": {
"category": ["news"]
}
}
]
}
}
```
<Aside type="tip">
Sample content is optional during setup. Users can uncheck "Include sample content" in the Setup
Wizard if they want a clean start.
</Aside>
## Including Media
Reference images in sample content using the `$media` syntax.
For remote images:
```json
{
"data": {
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx",
"alt": "A descriptive alt text",
"filename": "hero.jpg"
}
}
}
}
```
For local images, place files in `.emdash/uploads/` and reference them:
```json
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "A descriptive alt text"
}
}
}
}
```
During seeding, media files are downloaded (or read locally) and uploaded to storage.
## Search
If your theme includes a search page, use the `LiveSearch` component for instant results:
```astro title="src/pages/search.astro"
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
/>
</Base>
```
`LiveSearch` provides debounced instant search with prefix matching, Porter stemming, and highlighted result snippets. Search must be enabled per-collection in the admin UI (Content Types > Edit > Features > Search).
## Testing Your Theme
1. Create a test project from your theme:
```bash
npm create astro@latest -- --template ./path/to/my-theme
```
2. Install dependencies and start the dev server:
```bash
cd test-site
npm install
npm run dev
```
3. Complete the Setup Wizard at `http://localhost:4321/_emdash/admin`
4. Verify collections, menus, redirects, and content were created correctly
5. Test all page templates render properly
6. Create new content through the admin to verify all fields work
## Publishing Your Theme
Publish to npm for distribution:
```bash
npm publish --access public
```
Users can then install your theme:
```bash
npm create astro@latest -- --template @your-org/emdash-theme-blog
```
For GitHub-hosted themes:
```bash
npm create astro@latest -- --template github:your-org/emdash-theme-blog
```
## Custom Portable Text Blocks
Themes can define custom Portable Text block types for specialized content. This is useful for marketing pages, landing pages, or any content that needs structured components beyond standard rich text.
### Defining Custom Blocks in Seed Content
Use a namespaced `_type` in your seed file's Portable Text content:
```json title=".emdash/seed.json"
{
"content": {
"pages": [
{
"id": "home",
"slug": "home",
"status": "published",
"data": {
"title": "Home",
"content": [
{
"_type": "marketing.hero",
"headline": "Build something amazing",
"subheadline": "The all-in-one platform for modern teams.",
"primaryCta": { "label": "Get Started", "url": "/signup" }
},
{
"_type": "marketing.features",
"_key": "features",
"headline": "Everything you need",
"features": [
{
"icon": "zap",
"title": "Lightning fast",
"description": "Built for speed."
}
]
}
]
}
}
]
}
}
```
### Creating Block Components
Create Astro components for each custom block type:
```astro title="src/components/blocks/Hero.astro"
---
interface Props {
value: {
headline: string;
subheadline?: string;
primaryCta?: { label: string; url: string };
};
}
const { value } = Astro.props;
---
<section class="hero">
<h1>{value.headline}</h1>
{value.subheadline && <p>{value.subheadline}</p>}
{value.primaryCta && (
<a href={value.primaryCta.url} class="btn">
{value.primaryCta.label}
</a>
)}
</section>
```
### Rendering Custom Blocks
Pass your custom block components to the `PortableText` component:
```astro title="src/components/MarketingBlocks.astro"
---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
interface Props {
value: unknown[];
}
const { value } = Astro.props;
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={value} components={{ types: marketingTypes }} />
```
Then use it in your pages:
```astro title="src/pages/index.astro"
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />
```
<Aside>
Custom block types don't have admin UI editors by default. Users can edit the seeded content
through the standard Portable Text editor or modify the JSON directly. For a full admin editing
experience, consider creating a plugin with custom editor components.
</Aside>
### Anchor IDs for Navigation
Add `_key` to blocks that should be linkable:
```json
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
```
Then use it as an anchor in your component:
```astro
<section id={value._key}>
<!-- content -->
</section>
```
This enables navigation links like `/#features`.
## Theme Checklist
Before publishing, verify your theme includes:
- [ ] `package.json` with `emdash` field (label, description, seed path)
- [ ] `.emdash/seed.json` with valid schema
- [ ] All collections referenced in pages exist in the seed
- [ ] Menus used in layouts are defined in the seed
- [ ] Sample content demonstrates the theme's design
- [ ] `astro.config.mjs` with database and storage configuration
- [ ] `src/live.config.ts` with EmDash loader
- [ ] No `getStaticPaths()` on content pages
- [ ] No hard-coded site title, tagline, or navigation
- [ ] Image fields accessed as objects (`image.src`), not strings
- [ ] README with setup instructions
- [ ] Custom block components for any non-standard Portable Text types
## Next Steps
- **[Seed File Format](/themes/seed-files/)** -- Complete reference for seed files
- **[Themes Overview](/themes/overview/)** -- How themes work in EmDash
- **[Porting WordPress Themes](/themes/porting-wp-themes/)** -- Convert existing WordPress themes

View File

@@ -0,0 +1,160 @@
---
title: Themes Overview
description: Understand how EmDash themes work and how they bootstrap new sites.
---
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
An EmDash theme is a complete Astro site -- pages, layouts, components, styles -- distributed via `create-astro`. It also includes a **seed file** that bootstraps the database with collections, fields, menus, redirects, and sample content on first run.
## What a Theme Provides
A theme is a working Astro project with:
- **Pages** — Astro routes for rendering content (homepage, blog posts, archives, etc.)
- **Layouts** — Shared HTML structure
- **Components** — Reusable UI elements (navigation, cards, footers)
- **Styles** — CSS or Tailwind configuration
- **A seed file** — JSON that tells the CMS what content types and fields to create
<Aside>
EmDash gives you far more control over the content model than WordPress does. Themes take
advantage of this by declaring exactly which collections and fields they need via the seed file.
Most themes should build on the standard **posts** and **pages** collections, adding fields and
taxonomies as needed rather than inventing entirely new content types.
</Aside>
## Theme Structure
```
my-theme/
├── package.json # Theme metadata + EmDash config
├── astro.config.mjs # Astro integration setup
├── src/
│ ├── live.config.ts # Live Collections configuration
│ ├── pages/ # Astro routes
│ ├── layouts/ # Layout components
│ └── components/ # UI components
└── .emdash/
├── seed.json # Schema + sample content
└── uploads/ # Optional local media files
```
## How Themes Bootstrap Sites
When you create a site from a theme, this happens:
1. `create-astro` scaffolds the project from the template
2. You run `npm install` and `npm run dev`
3. On first admin visit, the **Setup Wizard** runs automatically
4. The wizard applies the seed file, creating collections, menus, redirects, and content
5. The site is ready to use
<CardGrid>
<Card title="For Users" icon="laptop">
Pick a theme, run the wizard, start editing. No database knowledge required.
</Card>
<Card title="For Developers" icon="seti:config">
Themes are standard Astro projects. Customize freely after scaffolding.
</Card>
</CardGrid>
## Installing a Theme
Use `create-astro` with a template:
```bash
npm create astro@latest -- --template @emdash-cms/template-blog
```
Community themes work via GitHub:
```bash
npm create astro@latest -- --template github:user/emdash-portfolio
```
After installation:
```bash
cd my-site
npm install
npm run dev
```
Visit `http://localhost:4321/_emdash/admin` to complete the Setup Wizard.
## The Setup Wizard
The Setup Wizard runs automatically on first admin visit. It:
1. Prompts for site title, tagline, and admin credentials
2. Offers an option to include sample content
3. Applies the seed file to the database
4. Redirects to the admin dashboard
```
┌────────────────────────────────────────────────────────┐
│ │
│ ◆ EmDash │
│ │
│ Welcome to your new site │
│ │
│ Site Title: [My Awesome Blog ] │
│ Tagline: [Thoughts and ideas ] │
│ │
│ Admin Email: [admin@example.com ] │
│ Admin Password: [•••••••••••• ] │
│ │
│ ☑ Include sample content │
│ │
│ [Create Site →] │
│ │
│ Template: Blog Starter │
│ Creates: 2 collections, 3 pages, 1 post │
└────────────────────────────────────────────────────────┘
```
<Aside type="tip">
Check "Include sample content" when exploring a theme for the first time. The sample content
demonstrates how the theme expects content to be structured.
</Aside>
## Official Themes
EmDash provides official starter themes, each available in local (SQLite + filesystem) and Cloudflare (D1 + R2) variants:
| Theme | Description | Use Case |
| ----- | ----------- | -------- |
| `@emdash-cms/template-blog` | Minimal blog with posts, pages, categories, and dark mode | Personal blogs, simple sites |
| `@emdash-cms/template-portfolio` | Editorial-style portfolio with projects, serif typography (Playfair Display), and image-focused layouts | Freelancers, agencies, creatives |
| `@emdash-cms/template-marketing` | Bold marketing site with custom Portable Text blocks (hero, features, testimonials, pricing, FAQ) | Landing pages, SaaS sites, product marketing |
### Cloudflare Variants
For deployment on Cloudflare Pages with D1 and R2, append `-cloudflare` to the template name:
```bash
npm create astro@latest -- --template @emdash-cms/template-blog-cloudflare
npm create astro@latest -- --template @emdash-cms/template-portfolio-cloudflare
npm create astro@latest -- --template @emdash-cms/template-marketing-cloudflare
```
These variants include `wrangler.jsonc` for deployment configuration.
## Customizing After Install
After the Setup Wizard completes, your site is a standard Astro project. Customize it like any Astro site:
- Edit pages in `src/pages/`
- Modify layouts in `src/layouts/`
- Add collections via the admin UI
- Install Astro integrations
- Deploy anywhere Astro runs
The seed file is only used during initial setup. Once applied, your schema lives in the database.
## Next Steps
- **[Creating Themes](/themes/creating-themes/)** — Build your own EmDash theme
- **[Seed File Format](/themes/seed-files/)** — Reference for seed file structure
- **[Getting Started](/getting-started/)** — Create your first EmDash site

View File

@@ -0,0 +1,457 @@
---
title: Porting WordPress Themes
description: Convert WordPress themes to EmDash themes using a structured approach
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
WordPress themes can be systematically converted to EmDash. The visual design, content structure, and dynamic features all transfer using a three-phase approach.
<Aside type="tip" title="AI-Assisted Porting">
AI coding agents excel at mechanical conversions like template porting. Feed the agent your
WordPress theme files along with the concept mapping tables in this guide, and it can generate a
solid first draft of Astro components. Review and refine the output—the agent handles the tedious
parts while you focus on quality.
</Aside>
## Three-Phase Approach
<Steps>
1. **Design Extraction**
Extract CSS variables, fonts, colors, and layout patterns from the WordPress theme. Analyze the live site to capture computed styles and responsive breakpoints.
2. **Template Conversion**
Convert PHP templates to Astro components. Map the WordPress template hierarchy to Astro routes and transform template tags to EmDash API calls.
3. **Dynamic Features**
Port navigation menus, widget areas, taxonomies, and site settings to their EmDash equivalents. Create a seed file to capture the complete content model.
</Steps>
## Phase 1: Design Extraction
### Locate CSS and Design Tokens
| File | Purpose |
| ------------- | ------------------------------------------ |
| `style.css` | Main stylesheet with theme header |
| `assets/css/` | Additional stylesheets |
| `theme.json` | Block themes (WP 5.9+) - structured tokens |
### Extract Design Tokens
| WordPress Pattern | EmDash Variable |
| ----------------- | ------------------ |
| Body font family | `--font-body` |
| Heading font | `--font-heading` |
| Primary color | `--color-primary` |
| Background | `--color-base` |
| Text color | `--color-contrast` |
| Content width | `--content-width` |
### Create Base Layout
Create `src/layouts/Base.astro` with extracted CSS variables, header/footer structure, font loading, and responsive breakpoints:
```astro title="src/layouts/Base.astro"
---
import { getSiteSettings, getMenu } from "emdash";
import "../styles/global.css";
const { title, description } = Astro.props;
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
const pageTitle = title ? `${title} | ${settings.title}` : settings.title;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{pageTitle}</title>
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
<nav>
{primaryMenu?.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main><slot /></main>
</body>
</html>
```
## Phase 2: Template Conversion
### Template Hierarchy Mapping
| WordPress Template | Astro Route |
| --------------------------- | ----------------------------------- |
| `index.php` | `src/pages/index.astro` |
| `single.php` | `src/pages/posts/[slug].astro` |
| `single-{post_type}.php` | `src/pages/{type}/[slug].astro` |
| `page.php` | `src/pages/[...slug].astro` |
| `archive.php` | `src/pages/posts/index.astro` |
| `category.php` | `src/pages/categories/[slug].astro` |
| `tag.php` | `src/pages/tags/[slug].astro` |
| `search.php` | `src/pages/search.astro` |
| `404.php` | `src/pages/404.astro` |
| `header.php` / `footer.php` | Part of `src/layouts/Base.astro` |
| `sidebar.php` | `src/components/Sidebar.astro` |
### Template Tags Mapping
| WordPress Function | EmDash Equivalent |
| ----------------------------- | -------------------------------------------- |
| `have_posts()` / `the_post()` | `getEmDashCollection()` |
| `get_post()` | `getEmDashEntry()` |
| `the_title()` | `post.data.title` |
| `the_content()` | `<PortableText value={post.data.content} />` |
| `the_excerpt()` | `post.data.excerpt` |
| `the_permalink()` | `/posts/${post.slug}` |
| `the_post_thumbnail()` | `post.data.featured_image` |
| `get_the_date()` | `post.data.publishedAt` |
| `get_the_category()` | `getEntryTerms(coll, id, "categories")` |
| `get_the_tags()` | `getEntryTerms(coll, id, "tags")` |
### Converting The Loop
<Tabs>
<TabItem label="WordPress">
```php title="archive.php"
<?php while (have_posts()) : the_post(); ?>
<article>
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
<?php the_excerpt(); ?>
</article>
<?php endwhile; ?>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/pages/posts/index.astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
const { entries: posts } = await getEmDashCollection("posts", {
where: { status: "published" },
orderBy: { publishedAt: "desc" },
});
---
<Base title="Blog">
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</Base>
```
</TabItem>
</Tabs>
### Converting Single Templates
<Tabs>
<TabItem label="WordPress">
```php title="single.php"
<?php get_header(); ?>
<article>
<h1><?php the_title(); ?></h1>
<?php the_content(); ?>
<div class="post-meta">
Posted in: <?php the_category(', '); ?>
</div>
</article>
<?php get_footer(); ?>
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts");
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const categories = await getEntryTerms("posts", post.id, "categories");
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<div class="post-meta">
Posted in: {categories.map((cat) => (
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
))}
</div>
</article>
</Base>
```
</TabItem>
</Tabs>
### Converting Template Parts
WordPress `get_template_part()` calls become Astro component imports. The `template-parts/content-post.php` partial becomes a `PostCard.astro` component that you import and render in a loop.
## Phase 3: Dynamic Features
### Navigation Menus
Identify menus in `functions.php` and create corresponding EmDash menus:
```astro title="src/components/PrimaryNav.astro"
---
import { getMenu } from "emdash";
const menu = await getMenu("primary");
---
{menu && (
<nav class="primary-nav">
<ul>
{menu.items.map((item) => (
<li>
<a href={item.url} aria-current={Astro.url.pathname === item.url ? "page" : undefined}>
{item.label}
</a>
{item.children.length > 0 && (
<ul class="submenu">
{item.children.map((child) => (
<li><a href={child.url}>{child.label}</a></li>
))}
</ul>
)}
</li>
))}
</ul>
</nav>
)}
```
### Widget Areas (Sidebars)
Identify widget areas in the theme and render them:
```astro title="src/components/Sidebar.astro"
---
import { getWidgetArea, getMenu } from "emdash";
import { PortableText } from "emdash/ui";
import RecentPosts from "./widgets/RecentPosts.astro";
const sidebar = await getWidgetArea("sidebar");
const widgetComponents = { "core:recent-posts": RecentPosts };
---
{sidebar && sidebar.widgets.length > 0 && (
<aside class="sidebar">
{sidebar.widgets.map(async (widget) => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && <PortableText value={widget.content} />}
{widget.type === "menu" && (
<nav>
{await getMenu(widget.menuName).then((m) =>
m?.items.map((item) => <a href={item.url}>{item.label}</a>)
)}
</nav>
)}
{widget.type === "component" && widgetComponents[widget.componentId] && (
<Fragment>
{(() => {
const Component = widgetComponents[widget.componentId];
return <Component {...widget.componentProps} />;
})()}
</Fragment>
)}
</div>
))}
</aside>
)}
```
### Widget Type Mapping
| WordPress Widget | EmDash Widget Type |
| ---------------- | -------------------------------- |
| Text/Custom HTML | `type: "content"` |
| Custom Menu | `type: "menu"` |
| Recent Posts | `component: "core:recent-posts"` |
| Categories | `component: "core:categories"` |
| Tag Cloud | `component: "core:tag-cloud"` |
| Search | `component: "core:search"` |
### Taxonomies
Query taxonomies registered in the theme:
```astro title="src/pages/genres/[slug].astro"
---
import { getTaxonomyTerms, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const genres = await getTaxonomyTerms("genre");
return genres.map((genre) => ({
params: { slug: genre.slug },
props: { genre },
}));
}
const { genre } = Astro.props;
const books = await getEntriesByTerm("books", "genre", genre.slug);
---
<Base title={genre.label}>
<h1>{genre.label}</h1>
{books.map((book) => (
<article>
<h2><a href={`/books/${book.slug}`}>{book.data.title}</a></h2>
</article>
))}
</Base>
```
### Site Settings Mapping
| WordPress Customizer | EmDash Setting |
| -------------------- | ---------------- |
| Site Title | `title` |
| Tagline | `tagline` |
| Site Icon | `favicon` |
| Custom Logo | `logo` |
| Posts per page | `postsPerPage` |
## Shortcodes to Portable Text
WordPress shortcodes become Portable Text custom blocks:
<Tabs>
<TabItem label="WordPress">
```php title="functions.php"
add_shortcode('gallery', function($atts) {
$ids = explode(',', $atts['ids']);
return '<div class="gallery">...</div>';
});
```
</TabItem>
<TabItem label="EmDash">
```astro title="src/components/blocks/Gallery.astro"
---
const { images } = Astro.props;
---
<div class="gallery">
{images.map((img) => (
<img src={img.url} alt={img.alt || ""} loading="lazy" />
))}
</div>
```
Register with PortableText:
```astro
<PortableText value={content} components={{ gallery: Gallery }} />
```
</TabItem>
</Tabs>
## Seed File Structure
Capture the complete content model in a seed file. Include settings, taxonomies, menus, and widget areas:
```json title=".emdash/seed.json"
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": { "name": "Ported Theme" },
"settings": { "title": "My Site", "tagline": "Welcome", "postsPerPage": 10 },
"taxonomies": [
{
"name": "category",
"label": "Categories",
"hierarchical": true,
"collections": ["posts"]
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "Blog", "url": "/posts" }
]
}
],
"widgetAreas": [
{
"name": "sidebar",
"label": "Main Sidebar",
"widgets": [
{
"type": "component",
"componentId": "core:recent-posts",
"props": { "count": 5 }
}
]
}
]
}
```
See [Seed File Format](/themes/seed-files/) for the complete specification.
## Porting Checklist
**Phase 1 (Design):** CSS variables extracted, fonts loading, color scheme matches, responsive breakpoints work.
**Phase 2 (Templates):** Homepage, single posts, archives, and 404 page all render correctly.
**Phase 3 (Dynamic):** Site settings configured, menus functional, taxonomies queryable, widget areas rendering, seed file complete.
## Edge Cases
### Child Themes
If the theme has a parent (check `style.css` for `Template:`), analyze the parent theme first, then apply child theme overrides.
### Block Themes (FSE)
WordPress 5.9+ block themes use `theme.json` for design tokens and `templates/*.html` for block markup. Convert block markup to Astro components and extract tokens from `theme.json`.
### Page Builders
Content built with Elementor, Divi, or similar is stored in post meta, not theme files. This content imports via WXR, not theme porting. Focus theme porting on the shell—page builder content renders through Portable Text after import.
## Next Steps
- **[Creating Themes](/themes/creating-themes/)** — Build distributable EmDash themes
- **[Seed File Format](/themes/seed-files/)** — Complete seed file specification
- **[Migrate from WordPress](/migration/from-wordpress/)** — Import WordPress content

View File

@@ -0,0 +1,679 @@
---
title: Seed File Format
description: Reference for EmDash seed file structure and syntax.
---
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
Seed files are JSON documents that bootstrap EmDash sites. They define collections, fields, taxonomies, menus, redirects, widget areas, site settings, and optional sample content.
## Root Structure
```json
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {},
"settings": {},
"collections": [],
"taxonomies": [],
"bylines": [],
"menus": [],
"redirects": [],
"widgetAreas": [],
"sections": [],
"content": {}
}
```
| Field | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------------- |
| `$schema` | `string` | No | JSON schema URL for editor validation |
| `version` | `"1"` | Yes | Seed format version |
| `meta` | `object` | No | Metadata about the seed |
| `settings` | `object` | No | Site settings |
| `collections` | `array` | No | Collection definitions |
| `taxonomies` | `array` | No | Taxonomy definitions |
| `bylines` | `array` | No | Byline profile definitions |
| `menus` | `array` | No | Navigation menus |
| `redirects` | `array` | No | Redirect rules |
| `widgetAreas` | `array` | No | Widget area definitions |
| `sections` | `array` | No | Reusable content blocks |
| `content` | `object` | No | Sample content entries |
## Meta
Optional metadata about the seed:
```json
{
"meta": {
"name": "Blog Starter",
"description": "A simple blog with posts, pages, and categories",
"author": "EmDash"
}
}
```
## Settings
Site-wide configuration values:
```json
{
"settings": {
"title": "My Site",
"tagline": "A modern CMS",
"postsPerPage": 10,
"dateFormat": "MMMM d, yyyy"
}
}
```
Settings are applied to the `options` table with the `site:` prefix. The Setup Wizard will prefill `title` and `tagline` from the seed file (if provided), allowing users to override them during initial setup.
## Collections
Collection definitions create content types in the database:
```json
{
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"description": "Blog posts",
"icon": "file-text",
"supports": ["drafts", "revisions"],
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true
},
{
"slug": "content",
"label": "Content",
"type": "portableText"
},
{
"slug": "featured_image",
"label": "Featured Image",
"type": "image"
}
]
}
]
}
```
### Collection Properties
| Property | Type | Required | Description |
| --------------- | -------- | -------- | -------------------------------------------- |
| `slug` | `string` | Yes | URL-safe identifier (lowercase, underscores) |
| `label` | `string` | Yes | Plural display name |
| `labelSingular` | `string` | No | Singular display name |
| `description` | `string` | No | Admin UI description |
| `icon` | `string` | No | Lucide icon name |
| `supports` | `array` | No | Features: `"drafts"`, `"revisions"` |
| `fields` | `array` | Yes | Field definitions |
### Field Properties
| Property | Type | Required | Description |
| -------------- | --------- | -------- | ------------------------------------ |
| `slug` | `string` | Yes | Column name (lowercase, underscores) |
| `label` | `string` | Yes | Display name |
| `type` | `string` | Yes | Field type |
| `required` | `boolean` | No | Validation: field must have a value |
| `unique` | `boolean` | No | Validation: value must be unique |
| `defaultValue` | `any` | No | Default value for new entries |
| `validation` | `object` | No | Additional validation rules |
| `widget` | `string` | No | Admin UI widget override |
| `options` | `object` | No | Widget-specific configuration |
### Field Types
| Type | Description | Stored As |
| -------------- | -------------------------- | ----------------- |
| `string` | Short text | `TEXT` |
| `text` | Long text (textarea) | `TEXT` |
| `number` | Numeric value | `REAL` |
| `integer` | Whole number | `INTEGER` |
| `boolean` | True/false | `INTEGER` |
| `date` | Date value | `TEXT` (ISO 8601) |
| `datetime` | Date and time | `TEXT` (ISO 8601) |
| `email` | Email address | `TEXT` |
| `url` | URL | `TEXT` |
| `slug` | URL-safe string | `TEXT` |
| `portableText` | Rich text content | `JSON` |
| `image` | Image reference | `JSON` |
| `file` | File reference | `JSON` |
| `json` | Arbitrary JSON | `JSON` |
| `reference` | Reference to another entry | `TEXT` |
## Taxonomies
Classification systems for content:
```json
{
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" },
{
"slug": "advanced",
"label": "Advanced Tutorials",
"parent": "tutorials"
}
]
},
{
"name": "tag",
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["posts"]
}
]
}
```
### Taxonomy Properties
| Property | Type | Required | Description |
| --------------- | --------- | -------- | ---------------------------------------------- |
| `name` | `string` | Yes | Unique identifier |
| `label` | `string` | Yes | Plural display name |
| `labelSingular` | `string` | No | Singular display name |
| `hierarchical` | `boolean` | Yes | Allow nested terms (categories) or flat (tags) |
| `collections` | `array` | Yes | Collections this taxonomy applies to |
| `terms` | `array` | No | Pre-defined terms |
### Term Properties
| Property | Type | Required | Description |
| ------------- | -------- | -------- | ------------------------------------ |
| `slug` | `string` | Yes | URL-safe identifier |
| `label` | `string` | Yes | Display name |
| `description` | `string` | No | Term description |
| `parent` | `string` | No | Parent term slug (hierarchical only) |
## Menus
Navigation menus editable from the admin:
```json
{
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "page", "ref": "about" },
{ "type": "custom", "label": "Blog", "url": "/posts" },
{
"type": "custom",
"label": "External",
"url": "https://example.com",
"target": "_blank"
}
]
}
]
}
```
### Menu Item Types
| Type | Description | Required Fields |
| ------------ | ---------------------------- | ------------------- |
| `custom` | Custom URL | `url` |
| `page` | Link to a page entry | `ref` |
| `post` | Link to a post entry | `ref` |
| `taxonomy` | Link to a taxonomy archive | `ref`, `collection` |
| `collection` | Link to a collection archive | `collection` |
### Menu Item Properties
| Property | Type | Description |
| ------------ | -------- | ------------------------------------------------ |
| `type` | `string` | Item type (see above) |
| `label` | `string` | Display text (auto-generated for page/post refs) |
| `url` | `string` | Custom URL (for `custom` type) |
| `ref` | `string` | Content ID in seed (for `page`/`post` types) |
| `collection` | `string` | Collection slug |
| `target` | `string` | `"_blank"` for new window |
| `titleAttr` | `string` | HTML title attribute |
| `cssClasses` | `string` | Custom CSS classes |
| `children` | `array` | Nested menu items |
## Bylines
Byline profiles are separate from ownership (`author_id`). Define reusable byline identities once, then reference them from content entries.
```json
{
"bylines": [
{
"id": "editorial",
"slug": "emdash-editorial",
"displayName": "EmDash Editorial"
},
{
"id": "guest",
"slug": "guest-contributor",
"displayName": "Guest Contributor",
"isGuest": true
}
]
}
```
| Property | Type | Required | Description |
| ------------ | --------- | -------- | ------------------------------------------ |
| `id` | `string` | Yes | Seed-local ID used by `content[].bylines` |
| `slug` | `string` | Yes | URL-safe byline slug |
| `displayName`| `string` | Yes | Name shown in templates and APIs |
| `bio` | `string` | No | Optional profile bio |
| `websiteUrl` | `string` | No | Optional website URL |
| `isGuest` | `boolean` | No | Marks byline as guest profile |
## Redirects
Redirect rules to preserve legacy URLs after migration:
```json
{
"redirects": [
{ "source": "/old-about", "destination": "/about" },
{ "source": "/legacy-feed", "destination": "/rss.xml", "type": 308 },
{
"source": "/category/news",
"destination": "/categories/news",
"groupName": "migration"
}
]
}
```
### Redirect Properties
| Property | Type | Required | Description |
| ------------- | --------- | -------- | -------------------------------------------------- |
| `source` | `string` | Yes | Source path (must start with `/`) |
| `destination` | `string` | Yes | Destination path (must start with `/`) |
| `type` | `number` | No | HTTP status: `301`, `302`, `307`, or `308` |
| `enabled` | `boolean` | No | Whether the redirect is active (default: `true`) |
| `groupName` | `string` | No | Optional grouping label for admin filtering/search |
<Aside type="caution">
`source` and `destination` must be local paths. External URLs, protocol-relative paths (`//...`),
path traversal segments (`..`), and newline characters are rejected by seed validation.
</Aside>
## Widget Areas
Configurable content regions:
```json
{
"widgetAreas": [
{
"name": "sidebar",
"label": "Main Sidebar",
"description": "Appears on blog posts and pages",
"widgets": [
{
"type": "component",
"title": "Recent Posts",
"componentId": "core:recent-posts",
"props": { "count": 5 }
},
{
"type": "menu",
"title": "Quick Links",
"menuName": "footer"
},
{
"type": "content",
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to our site!" }]
}
]
}
]
}
]
}
```
### Widget Types
| Type | Description | Required Fields |
| ----------- | -------------------- | ------------------------- |
| `content` | Rich text content | `content` (Portable Text) |
| `menu` | Renders a menu | `menuName` |
| `component` | Registered component | `componentId` |
### Built-in Components
| Component ID | Description |
| ------------------- | -------------------- |
| `core:recent-posts` | List of recent posts |
| `core:categories` | Category list |
| `core:tags` | Tag cloud |
| `core:search` | Search form |
| `core:archives` | Monthly archives |
## Sections
Reusable content blocks that editors can insert into Portable Text fields via the `/section` slash command:
```json
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA button",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Your compelling tagline goes here." }
]
}
]
}
]
}
```
### Section Properties
| Property | Type | Required | Description |
| ------------- | -------- | -------- | ----------------------------------------------- |
| `slug` | `string` | Yes | URL-safe identifier |
| `title` | `string` | Yes | Display name shown in the section picker |
| `description` | `string` | No | Explains when to use this section |
| `keywords` | `array` | No | Search terms for finding the section |
| `content` | `array` | Yes | Portable Text blocks |
| `source` | `string` | No | `"theme"` (default for seeds) or `"import"` |
Sections from seed files are marked `source: "theme"` and cannot be deleted from the admin UI. Editors can create their own sections (`source: "user"`) and insert any section type when editing content.
## Content
Sample content organized by collection:
```json
{
"content": {
"posts": [
{
"id": "hello-world",
"slug": "hello-world",
"status": "published",
"bylines": [
{ "byline": "editorial" },
{ "byline": "guest", "roleLabel": "Guest essay" }
],
"data": {
"title": "Hello World",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome!" }]
}
],
"excerpt": "Your first post."
},
"taxonomies": {
"category": ["news"],
"tag": ["welcome", "first-post"]
}
}
],
"pages": [
{
"id": "about",
"slug": "about",
"status": "published",
"data": {
"title": "About Us",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "About page content." }]
}
]
}
}
]
}
}
```
### Content Entry Properties
| Property | Type | Required | Description |
| ------------ | -------- | -------- | --------------------------------------------------- |
| `id` | `string` | Yes | Seed-local ID for references |
| `slug` | `string` | Yes | URL slug |
| `status` | `string` | No | `"published"` or `"draft"` (default: `"published"`) |
| `data` | `object` | Yes | Field values |
| `bylines` | `array` | No | Ordered byline credits (`byline`, optional `roleLabel`) |
| `taxonomies` | `object` | No | Term assignments by taxonomy name |
## Content References
Reference other content entries using the `$ref:` prefix:
```json
{
"data": {
"related_posts": ["$ref:another-post", "$ref:third-post"]
}
}
```
The `$ref:` prefix resolves seed IDs to database IDs during seeding.
## Media References
Include images from URLs:
```json
{
"data": {
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx",
"alt": "Description of the image",
"filename": "hero.jpg",
"caption": "Photo by Someone"
}
}
}
}
```
Include local images from `.emdash/media/`:
```json
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "Description of the image"
}
}
}
}
```
### Media Properties
| Property | Type | Required | Description |
| ---------- | -------- | -------- | ------------------------------------ |
| `url` | `string` | Yes\* | Remote URL to download |
| `file` | `string` | Yes\* | Local filename in `.emdash/media/` |
| `alt` | `string` | No | Alt text for accessibility |
| `filename` | `string` | No | Override filename |
| `caption` | `string` | No | Media caption |
\*Either `url` or `file` is required, not both.
<Aside>
Media is downloaded during seeding. Large images may slow down the Setup Wizard. Consider using
compressed images or thumbnail versions for sample content.
</Aside>
## Applying Seeds Programmatically
Use the seed API for CLI tools or scripts:
```typescript
import { applySeed, validateSeed } from "emdash/seed";
import seedData from "./.emdash/seed.json";
// Validate first
const validation = validateSeed(seedData);
if (!validation.valid) {
console.error(validation.errors);
process.exit(1);
}
// Apply seed
const result = await applySeed(db, seedData, {
includeContent: true,
onConflict: "skip",
storage: myStorage,
baseUrl: "http://localhost:4321",
});
console.log(result);
// {
// collections: { created: 2, skipped: 0 },
// fields: { created: 8, skipped: 0 },
// taxonomies: { created: 2, terms: 5 },
// bylines: { created: 2, skipped: 0 },
// menus: { created: 1, items: 4 },
// redirects: { created: 3, skipped: 0 },
// widgetAreas: { created: 1, widgets: 3 },
// settings: { applied: 3 },
// content: { created: 3, skipped: 0 },
// media: { created: 2, skipped: 0 }
// }
```
### Apply Options
| Option | Type | Default | Description |
| ---------------- | --------- | -------- | ---------------------------------- |
| `includeContent` | `boolean` | `false` | Create sample content entries |
| `onConflict` | `string` | `"skip"` | `"skip"`, `"update"`, or `"error"` |
| `mediaBasePath` | `string` | — | Base path for local media files |
| `storage` | `Storage` | — | Storage adapter for media uploads |
| `baseUrl` | `string` | — | Base URL for media URLs |
## Idempotency
Seeding is safe to run multiple times. Conflict behavior by entity type:
| Entity | Behavior |
| ------------------- | ------------------------------------- |
| Collection | Skip if slug exists |
| Field | Skip if collection + slug exists |
| Taxonomy definition | Skip if name exists |
| Taxonomy term | Skip if name + slug exists |
| Byline profile | Skip if slug exists |
| Menu | Skip if name exists |
| Menu items | Replace all (menu is recreated) |
| Redirect | Skip if source exists |
| Widget area | Skip if name exists |
| Widgets | Replace all (area is recreated) |
| Section | Skip if slug exists |
| Settings | Update (settings are meant to change) |
| Content | Skip if slug exists in collection |
<Aside type="caution">
Menu items and widgets are **replaced**, not merged. The seed file is the source of truth for menu
and widget area structure.
</Aside>
## Validation
Seed files are validated before application:
```typescript
import { validateSeed } from "emdash/seed";
const { valid, errors, warnings } = validateSeed(seedData);
if (!valid) {
errors.forEach((e) => console.error(e));
}
warnings.forEach((w) => console.warn(w));
```
Validation checks:
- Required fields are present
- Slugs follow naming conventions (lowercase, underscores)
- Field types are valid
- References point to existing content
- Hierarchical term parents exist
- Redirect paths are safe local URLs
- Redirect sources are unique
- No duplicate slugs within collections
## CLI Commands
```bash
# Apply seed file
npx emdash seed .emdash/seed.json
# Apply without sample content
npx emdash seed .emdash/seed.json --no-content
# Validate only
npx emdash seed .emdash/seed.json --validate
# Export current schema as seed
npx emdash export-seed > seed.json
# Export with content
npx emdash export-seed --with-content > seed.json
```
## Next Steps
- **[Creating Themes](/themes/creating-themes/)** — Build a complete theme
- **[Themes Overview](/themes/overview/)** — How themes work

View File

@@ -0,0 +1,102 @@
---
title: Why EmDash?
description: Understand what problems EmDash solves and how it compares to other approaches.
---
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
EmDash is an Astro-native CMS that combines traditional CMS patterns with modern web development: a content editing interface, Astro framework integration, and flexible deployment options.
## What Makes EmDash Different
### Astro-Native Architecture
EmDash is built specifically for Astro, not adapted from a generic CMS. Content lives in the same deployment as your site, queried through Astro's Live Content Collections. No separate services, no API round-trips, no webhook synchronization.
### Familiar Content Model
If you've used WordPress, EmDash's concepts will feel familiar: collections (like post types), taxonomies, menus, widget areas, and a media library. The mental model transfers—the implementation uses modern tooling.
### Framework Integration
EmDash is purpose-built for Astro. This tight integration enables type-safe queries, component-level caching, and integrated preview.
## Core Capabilities
<CardGrid>
<Card title="Single Deployment" icon="rocket">
Content and frontend deploy together. One codebase, one deployment, one system to manage.
</Card>
<Card title="Type Safety" icon="approve-check">
Schema lives in the database. TypeScript types flow from database to template with full
autocomplete.
</Card>
<Card title="Live Updates" icon="star">
Built on Astro's Live Content Collections. Content changes appear instantly—no rebuilds
needed.
</Card>
<Card title="Cloud-Portable" icon="setting">
Runs on Cloudflare Workers with D1 and R2, and also works with Node.js, SQLite, and any
S3-compatible storage.
</Card>
</CardGrid>
## How It Compares
Different CMS approaches suit different needs:
| Aspect | Traditional CMS | Headless CMS | EmDash |
| ------------------- | ---------------- | --------------- | --------------------- |
| **Architecture** | Monolithic | Decoupled | Integrated with Astro |
| **Content editing** | Built-in admin | Built-in admin | Built-in admin |
| **Frontend** | Themes/templates | Bring your own | Astro components |
| **Deployment** | Single server | CMS + frontend | Single deployment |
| **Type safety** | Runtime | API types | Full TypeScript |
| **Content updates** | Immediate | Webhook/rebuild | Immediate (SSR) |
| **Plugin model** | Same-process | API extensions | Sandboxed with hooks |
## Cloudflare Deployment
EmDash runs on any platform with SQLite and S3-compatible storage. It also supports Cloudflare-specific features:
- **D1** — SQLite at the edge with automatic replication
- **R2** — S3-compatible storage with no egress fees
- **Workers** — Global deployment with fast cold starts
## Plugin Migration
EmDash provides tools to help migrate WordPress plugin functionality:
- **Concept mapping** — WordPress hooks, filters, and APIs map to EmDash equivalents
- **Migration guides** — Documentation for porting specific plugin patterns
- **AI-assisted porting** — Documentation structured to help AI tools generate EmDash plugins from WordPress plugin code
Complex plugins still need human review, but for straightforward plugins, the migration guides reduce porting effort.
## When to Use EmDash
**EmDash is designed for:**
- New Astro projects that need a CMS
- WordPress migrations where you want modern tooling
- Sites with content editors who shouldn't touch code
- Projects deploying to Cloudflare
- Sites where type safety and developer experience matter
**EmDash may not be right for:**
- Non-Astro projects (it's tightly coupled to Astro)
- E-commerce (WooCommerce-scale features are not yet available)
- Existing headless architectures you're happy with
- Projects requiring WordPress's specific plugin ecosystem
## Get Started
<CardGrid>
<Card title="Quick Start" icon="rocket">
[Create your first site](/getting-started/) in under 5 minutes.
</Card>
<Card title="Migration Guide" icon="right-arrow">
[Migrate from WordPress](/migration/from-wordpress/) with content import and concept mapping.
</Card>
</CardGrid>

View File

@@ -0,0 +1,31 @@
/* EmDash Docs Custom Styles */
:root {
/* Brand colors */
--sl-color-accent-low: #1a1a2e;
--sl-color-accent: #4a6cf7;
--sl-color-accent-high: #7b91f7;
/* Typography refinements */
--sl-font:
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Open Sans", "Helvetica Neue", sans-serif;
--sl-font-mono:
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}
:root[data-theme="dark"] {
--sl-color-accent-low: #1a1a2e;
--sl-color-accent: #6b7fff;
--sl-color-accent-high: #a3b0ff;
}
/* Improve code block readability */
.expressive-code {
margin-block: 1.5rem;
}
/* Card styling for callouts */
.starlight-aside {
border-radius: 0.5rem;
}

86
docs/src/worker.ts Normal file
View File

@@ -0,0 +1,86 @@
import { handle } from "@astrojs/cloudflare/handler";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { createMcpHandler } from "agents/mcp";
import { z } from "zod";
/**
* Build a fresh McpServer per request. createMcpHandler is stateless, and the
* underlying transport asserts that the server is not already connected, so we
* cannot reuse a single server instance across requests.
*/
function buildMcpServer(env: Env): McpServer {
const server = new McpServer({
name: "emdash-docs",
version: "1.0.0",
});
server.registerTool(
"search_docs",
{
title: "Search EmDash documentation",
description:
"Search the EmDash CMS documentation. Returns relevant chunks with source URLs and similarity scores.",
inputSchema: {
query: z
.string()
.min(1)
.max(1000)
.describe("Natural-language query against the EmDash docs."),
max_results: z
.number()
.int()
.min(1)
.max(20)
.optional()
.describe("Maximum number of chunks to return. Defaults to 8."),
},
},
async ({ query, max_results }) => {
const limit = max_results ?? 8;
const results = await env.AI_SEARCH.search({
messages: [{ role: "user", content: query }],
ai_search_options: {
retrieval: { max_num_results: limit },
},
});
if (!results.chunks.length) {
return {
content: [
{
type: "text",
text: "No matching docs found.",
},
],
};
}
return {
content: results.chunks.map((chunk) => {
const source = chunk.item.key;
const score = typeof chunk.score === "number" ? chunk.score.toFixed(3) : "n/a";
return {
type: "text" as const,
text: `<result source="${source}" score="${score}">\n${chunk.text}\n</result>`,
};
}),
};
},
);
return server;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/mcp") {
const handler = createMcpHandler(buildMcpServer(env), { route: "/mcp" });
return handler(request, env, ctx);
}
return handle(request, env, ctx);
},
} satisfies ExportedHandler<Env>;

5
docs/tsconfig.json Normal file
View File

@@ -0,0 +1,5 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*", "./worker-configuration.d.ts"],
"exclude": ["dist"]
}

14056
docs/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

28
docs/wrangler.jsonc Normal file
View File

@@ -0,0 +1,28 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"compatibility_date": "2026-03-01",
"routes": [
{
"pattern": "docs.emdashcms.com",
"custom_domain": true,
"zone_name": "emdashcms.com",
},
],
"compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"],
"name": "docs",
"main": "./src/worker.ts",
"assets": {
"directory": "./dist",
"binding": "ASSETS",
},
"ai_search": [
{
"remote": true,
"binding": "AI_SEARCH",
"instance_name": "emdash-docs",
},
],
"observability": {
"enabled": true,
},
}