chore: import upstream snapshot with attribution
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
# EmDash Docs
|
||||
|
||||
Documentation site for EmDash, built with [Starlight](https://starlight.astro.build).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
If you're running in a remote or API-token-only environment that cannot access the
|
||||
Cloudflare AI Search instance, keep using `pnpm dev` for local content work and
|
||||
use the local-only Wrangler config for built-worker preview checks instead:
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
pnpm exec wrangler dev --config wrangler.local.jsonc
|
||||
```
|
||||
|
||||
The `/mcp` endpoint still works, but it returns a helpful message until a
|
||||
Cloudflare AI Search binding is configured.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
For remote docs preview/deploy checks that require production bindings, run the
|
||||
main config as usual:
|
||||
|
||||
```bash
|
||||
pnpm exec wrangler dev
|
||||
pnpm exec wrangler deploy
|
||||
```
|
||||
@@ -0,0 +1,255 @@
|
||||
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",
|
||||
disable404Route: true,
|
||||
components: {
|
||||
SkipLink: "./src/components/SkipLink.astro",
|
||||
},
|
||||
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: "Backups", slug: "guides/backups" },
|
||||
{
|
||||
label: "Internationalization (i18n)",
|
||||
slug: "guides/internationalization",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Plugins",
|
||||
items: [
|
||||
{ label: "Plugin Overview", slug: "plugins/overview" },
|
||||
{ label: "Installing Plugins", slug: "plugins/installing" },
|
||||
{ label: "Plugin Registry", slug: "plugins/registry" },
|
||||
{ label: "Upgrading Plugins", slug: "plugins/upgrading-sites" },
|
||||
],
|
||||
},
|
||||
{
|
||||
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: "Plugin Development",
|
||||
items: [
|
||||
{
|
||||
label: "Choosing a Plugin Format",
|
||||
slug: "plugins/creating-plugins/choosing-a-format",
|
||||
},
|
||||
{
|
||||
label: "Sandboxed Plugins",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: "Your First Plugin",
|
||||
slug: "plugins/creating-plugins/your-first-plugin",
|
||||
},
|
||||
{
|
||||
label: "The Manifest",
|
||||
slug: "plugins/creating-plugins/manifest",
|
||||
},
|
||||
{
|
||||
label: "The Plugin CLI",
|
||||
slug: "plugins/creating-plugins/cli",
|
||||
},
|
||||
{ label: "Hooks", slug: "plugins/creating-plugins/hooks" },
|
||||
{ label: "API Routes", slug: "plugins/creating-plugins/api-routes" },
|
||||
{ label: "Storage", slug: "plugins/creating-plugins/storage" },
|
||||
{ label: "Settings", slug: "plugins/creating-plugins/settings" },
|
||||
{ label: "Block Kit", slug: "plugins/creating-plugins/block-kit" },
|
||||
{
|
||||
label: "Capabilities & Security",
|
||||
slug: "plugins/creating-plugins/capabilities",
|
||||
},
|
||||
{
|
||||
label: "Bundling & Publishing",
|
||||
slug: "plugins/creating-plugins/publishing",
|
||||
},
|
||||
{
|
||||
label: "Migrating to the CLI",
|
||||
slug: "plugins/creating-plugins/migrating-to-the-cli",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Native Plugins",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{
|
||||
label: "Your First Native Plugin",
|
||||
slug: "plugins/creating-native-plugins/your-first-native-plugin",
|
||||
},
|
||||
{
|
||||
label: "React Admin Pages & Widgets",
|
||||
slug: "plugins/creating-native-plugins/react-admin",
|
||||
},
|
||||
{
|
||||
label: "Portable Text Components",
|
||||
slug: "plugins/creating-native-plugins/portable-text-components",
|
||||
},
|
||||
{
|
||||
label: "Page Fragments",
|
||||
slug: "plugins/creating-native-plugins/page-fragments",
|
||||
},
|
||||
{
|
||||
label: "Distributing Native Plugins",
|
||||
slug: "plugins/creating-native-plugins/distributing",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Querying the Registry",
|
||||
slug: "plugins/registry-client",
|
||||
},
|
||||
{ label: "Field Kit", slug: "plugins/field-kit" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Contributing",
|
||||
items: [
|
||||
{ label: "Contributor Guide", slug: "contributing" },
|
||||
{
|
||||
label: "Architecture (internals)",
|
||||
slug: "contributing/architecture",
|
||||
},
|
||||
{
|
||||
label: "Documentation Style Guide",
|
||||
slug: "contributing/docs-style-guide",
|
||||
},
|
||||
{ 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: "Deployment",
|
||||
items: [
|
||||
{ label: "Deploy to Cloudflare", slug: "deployment/cloudflare" },
|
||||
{ label: "Deploy to Node.js", slug: "deployment/nodejs" },
|
||||
{ label: "Evolving a Deployed Site", slug: "deployment/schema-evolution" },
|
||||
{ label: "Database Options", slug: "deployment/database" },
|
||||
{ label: "Storage Options", slug: "deployment/storage" },
|
||||
{ label: "Object Cache", slug: "deployment/object-cache" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Concepts",
|
||||
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({ remoteBindings: false, prerenderEnvironment: "node" }),
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://docs.emdashcms.com/sitemap-index.xml
|
||||
|
After Width: | Height: | Size: 96 KiB |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 402 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 122 KiB |
@@ -0,0 +1,32 @@
|
||||
---
|
||||
import Default from "@astrojs/starlight/components/SkipLink.astro";
|
||||
---
|
||||
|
||||
<Default />
|
||||
|
||||
<blockquote
|
||||
class="agent-docs-index"
|
||||
data-agent-docs-index="true"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<h2>Documentation Index</h2>
|
||||
<p>
|
||||
Fetch the complete documentation index at: <a href="/llms.txt">/llms.txt</a>
|
||||
</p>
|
||||
<p>Use this file to discover all available pages before exploring further.</p>
|
||||
</blockquote>
|
||||
|
||||
<style>
|
||||
/* Visually hidden but readable by screen readers and crawlers. */
|
||||
.agent-docs-index {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -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() }),
|
||||
};
|
||||
@@ -0,0 +1,542 @@
|
||||
---
|
||||
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 |
|
||||
|
||||
The following tree shows a typical Astro project layout:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
The following component declares typed props in the frontmatter and renders them in the 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>
|
||||
```
|
||||
|
||||
The following markup uses that component, passing the default slot and a named `footer` slot:
|
||||
|
||||
```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`. The following layout defines that shared shell and exposes a slot for page content:
|
||||
|
||||
```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>© {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, so component styles stay contained without escalating selector specificity.
|
||||
|
||||
### 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. The following page loads a component only when it scrolls into view:
|
||||
|
||||
```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">
|
||||
Routing is explicit: 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 |
|
||||
|
||||
## Concept Mapping
|
||||
|
||||
The main WordPress-to-Astro shifts covered in this guide:
|
||||
|
||||
- PHP templates become Astro components: server code plus HTML, with explicit file organization.
|
||||
- Template tags become props and imports: data flows through arguments instead of globals.
|
||||
- Theme files become a pages directory: URLs match the file structure.
|
||||
- Hooks become slots and middleware: insertion points are defined where content is passed in.
|
||||
- jQuery loads by default in WordPress; Astro ships no JavaScript until you add it.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,397 @@
|
||||
---
|
||||
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. 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 adds content management on top of your existing Astro 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, Cloudflare D1, or PostgreSQL |
|
||||
| **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">
|
||||
EmDash sites run in SSR mode by default, so content is served at runtime and changes appear
|
||||
immediately.
|
||||
</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/` | SQL database (SQLite, libSQL, D1, or Postgres) |
|
||||
| **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
|
||||
|
||||
The following configuration registers EmDash as an Astro integration in server output mode:
|
||||
|
||||
```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
|
||||
|
||||
The following file registers EmDash as a live content source:
|
||||
|
||||
```ts title="src/live.config.ts"
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({
|
||||
loader: emdashLoader(),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
The following layout fetches a menu by location and renders it with nested items:
|
||||
|
||||
```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
|
||||
|
||||
The following layout fetches a widget area and renders each widget:
|
||||
|
||||
```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
|
||||
|
||||
The following component reads global site settings and renders a logo or title:
|
||||
|
||||
```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, so content is served at runtime and changes appear immediately.
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,405 @@
|
||||
---
|
||||
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
|
||||
|
||||
The following examples fetch one entry by identifier and render it, in WordPress and in EmDash:
|
||||
|
||||
<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>
|
||||
```
|
||||
|
||||
The following page imports that component and renders it for each post:
|
||||
|
||||
```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` |
|
||||
|
||||
The following example reads individual settings with `getSiteSetting()`:
|
||||
|
||||
```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, so field names and
|
||||
return types autocomplete and are checked at build time.
|
||||
</Card>
|
||||
<Card title="Performance" icon="rocket">
|
||||
Static generation by default, with 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 live in git; content lives in the database. Deploy by pushing code.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
### Preview Links
|
||||
|
||||
EmDash generates secure preview URLs with HMAC-signed tokens. Content editors share a preview link to show a draft, so reviewers can see it without a production login.
|
||||
|
||||
### Isolated Plugins
|
||||
|
||||
EmDash plugins run in isolated contexts with explicit APIs. Each plugin reaches only the APIs it declares, so plugins do not share or overwrite each other's global state.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
title: Admin Panel
|
||||
description: What the EmDash admin panel offers editors, administrators, and developers.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
The admin panel is the content management interface for your site. It is served at `/_emdash/admin/` inside your Astro site, and it adapts automatically to your collections, plugins, and the signed-in user's role. For how it is built, see [Architecture (internals)](/contributing/architecture/#admin-panel-internals).
|
||||
|
||||
## Screens
|
||||
|
||||
| Path | Screen |
|
||||
| -------------------------- | ------------------------------- |
|
||||
| `/` | Dashboard |
|
||||
| `/content/:collection` | Content list |
|
||||
| `/content/:collection/:id` | Content editor |
|
||||
| `/content/:collection/new` | New entry |
|
||||
| `/media` | Media library |
|
||||
| `/content-types` | Schema builder (administrators) |
|
||||
| `/menus` | Navigation menus |
|
||||
| `/widgets` | Widget areas |
|
||||
| `/taxonomies` | Categories and tags |
|
||||
| `/settings` | Site settings |
|
||||
| `/plugins/:pluginId/*` | Plugin pages |
|
||||
|
||||
Navigation is generated from your collections and installed plugins, so a schema or plugin change appears in the admin immediately.
|
||||
|
||||
## Roles
|
||||
|
||||
What a user sees depends on their role. EmDash has five roles, from least to most access: Subscriber, Contributor, Author, Editor, Admin. See [User roles](/guides/authentication/#user-roles) for the full definitions.
|
||||
|
||||
In the admin, lower roles see only the content they have permission to manage. The schema builder at `/content-types` and the settings screens are Admin-only. Type generation and the CLI are available to developers working on the project regardless of admin role.
|
||||
|
||||
<Aside type="note">
|
||||
The admin requires a signed-in user. Login is a separate page that
|
||||
establishes a session; see [Authentication](/guides/authentication/).
|
||||
</Aside>
|
||||
|
||||
## Content editor
|
||||
|
||||
The content editor builds a form from a collection's fields. Each field type uses a matching input:
|
||||
|
||||
| Field type | Editor |
|
||||
| -------------- | ----------------- |
|
||||
| `string` | Text input |
|
||||
| `text` | Textarea |
|
||||
| `number` | Number input |
|
||||
| `boolean` | Toggle |
|
||||
| `datetime` | Date/time picker |
|
||||
| `select` | Dropdown |
|
||||
| `multiSelect` | Multi-select |
|
||||
| `portableText` | Rich text editor |
|
||||
| `image` | Media picker |
|
||||
| `reference` | Entry picker |
|
||||
|
||||
Rich text fields edit as formatted content — headings, lists, quotes, code, links, and images from the media library. Content from plugins or imports that the editor does not recognise is preserved untouched.
|
||||
|
||||
Lists are paginated and stay consistent even when content changes between pages.
|
||||
|
||||
## Media library
|
||||
|
||||
The media library supports:
|
||||
|
||||
- Grid and list views
|
||||
- Search and filter by type and date
|
||||
- Drag-and-drop upload
|
||||
- Image preview with metadata
|
||||
- Bulk selection and delete
|
||||
|
||||
Uploads go directly from the browser to your storage backend, so large files are not limited by request size.
|
||||
|
||||
## Plugin pages and widgets
|
||||
|
||||
A plugin can add pages and dashboard widgets to the admin. Plugin pages appear under `/_emdash/admin/plugins/:pluginId/`, and a plugin can only mount under its own namespace — it cannot override core admin screens. See [installing plugins](/plugins/installing/).
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Getting Started" icon="rocket">
|
||||
[Set up your first EmDash site](/getting-started/).
|
||||
</Card>
|
||||
<Card title="REST API" icon="puzzle">
|
||||
The admin uses the [REST API](/reference/rest-api/); you can call it too.
|
||||
</Card>
|
||||
<Card title="Architecture" icon="open-book">
|
||||
Review the [content model](/concepts/content-model/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: Architecture
|
||||
description: The mental model for building with EmDash — an Astro integration with a live, editable content model.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is an Astro integration. You add it to `astro.config.mjs`, choose a database and storage, and define content collections. This page covers the model you need to build a site. For internals (table layouts, the request path, code generation), see [Architecture (internals)](/contributing/architecture/).
|
||||
|
||||
## What EmDash adds to your site
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Your Astro site │
|
||||
│ │
|
||||
│ Pages and components you write │
|
||||
│ │ │
|
||||
│ │ getEmDashCollection() / getEmDashEntry()
|
||||
│ ▼ │
|
||||
│ ┌───────────────┐ ┌────────────────┐ │
|
||||
│ │ Content │ │ Admin panel │ │
|
||||
│ │ (your data) │◄────►│ /_emdash/admin │ │
|
||||
│ └───────────────┘ └────────────────┘ │
|
||||
│ │ │
|
||||
│ Database (SQLite / libSQL / D1 / Postgres) │
|
||||
│ Media storage (local / R2 / S3) │
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
You write pages and components as normal. EmDash provides the content, an admin panel for editing it, and the database and storage behind it. Editors work in the admin panel; your pages read the same content through query functions.
|
||||
|
||||
## Your content model
|
||||
|
||||
You define collections and fields — in the admin panel or with the CLI — and EmDash stores content against them.
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Change it anytime" icon="pencil">
|
||||
Add, rename, remove, or retype collections and fields whenever you need
|
||||
to. Changes take effect immediately.
|
||||
</Card>
|
||||
<Card title="Editor-designed" icon="seti:plan">
|
||||
A content editor can design the whole model through the admin UI.
|
||||
</Card>
|
||||
<Card title="Typed" icon="approve-check">
|
||||
Generate TypeScript types from the current model for autocomplete from
|
||||
query to template.
|
||||
</Card>
|
||||
<Card title="Portable" icon="right-arrow">
|
||||
Export the model as a JSON seed file for version control, and apply it in
|
||||
another environment.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
See the [content model](/concepts/content-model/) for how to define, change, type, and seed it.
|
||||
|
||||
## Content is live
|
||||
|
||||
EmDash serves content through Astro's Live Collections at runtime, so changes an editor makes are visible immediately. You read content with two functions:
|
||||
|
||||
```ts title="src/pages/blog.astro"
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
|
||||
```
|
||||
|
||||
See [Querying content](/guides/querying-content/) for filtering, pagination, and drafts.
|
||||
|
||||
## What you configure
|
||||
|
||||
You pass a database and a storage backend to the integration. Everything else has a default.
|
||||
|
||||
```ts 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" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
- **Database**: SQLite (local or libSQL), Cloudflare D1, or PostgreSQL. See [Database options](/deployment/database/).
|
||||
- **Storage**: the local filesystem, Cloudflare R2, or any S3-compatible storage for media. See [Storage options](/deployment/storage/).
|
||||
|
||||
## Extending with plugins
|
||||
|
||||
Plugins react to content and media lifecycle events and can add admin pages, dashboard widgets, and settings. There are two formats:
|
||||
|
||||
- **Native plugins** run in the host environment with full access. Best for first-party and trusted plugins.
|
||||
- **Sandboxed plugins** run in an isolated runtime with capability-based permissions. Best for third-party plugins.
|
||||
|
||||
See the [plugin overview](/plugins/overview/) to choose and install plugins, or [create a plugin](/plugins/creating-plugins/choosing-a-format/).
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Collections" icon="document">
|
||||
Define [content collections and field types](/concepts/collections/).
|
||||
</Card>
|
||||
<Card title="Content Model" icon="open-book">
|
||||
Understand the [content model](/concepts/content-model/).
|
||||
</Card>
|
||||
<Card title="Admin Panel" icon="setting">
|
||||
See what the [admin panel](/concepts/admin-panel/) offers editors and admins.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,384 @@
|
||||
---
|
||||
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";
|
||||
|
||||
A collection is a content type (posts, pages, products). Its field definitions set the shape of each entry's data.
|
||||
|
||||
## Creating collections
|
||||
|
||||
Create collections through the admin panel under **Content Types**. Each collection has the following properties:
|
||||
|
||||
<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 |
|
||||
|
||||
The following collection enables all four features:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Blog Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts", "revisions", "preview", "scheduling"]
|
||||
}
|
||||
```
|
||||
|
||||
## Field types
|
||||
|
||||
EmDash supports 16 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 and 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 and 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 16 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`, `terms`, `bylines`, `byline`.
|
||||
</Aside>
|
||||
|
||||
## Validation rules
|
||||
|
||||
The `validation` object varies by field type. Its full shape is:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
The following field requires a unique, pattern-matched email address:
|
||||
|
||||
```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. Its full shape is:
|
||||
|
||||
```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
|
||||
}
|
||||
```
|
||||
|
||||
The following reference field links to multiple products:
|
||||
|
||||
```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. The following example shows the common query options:
|
||||
|
||||
```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. The generated file contains one interface per collection:
|
||||
|
||||
```ts title=".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 as follows:
|
||||
|
||||
| Field Type | SQLite Type | Notes |
|
||||
| -------------- | ----------- | --------------------- |
|
||||
| `string` | `TEXT` | |
|
||||
| `text` | `TEXT` | |
|
||||
| `slug` | `TEXT` | |
|
||||
| `url` | `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 |
|
||||
| `repeater` | `JSON` | Array of sub-fields |
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Content Model" icon="open-book">
|
||||
Understand the [content model](/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>
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
title: Content Model
|
||||
description: How you shape and evolve your content model in EmDash — collections, fields, runtime changes, types, and seeds.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
Your content model is the set of collections and fields your site stores. You define it in the admin panel or with the CLI, change it whenever you need to, and optionally generate TypeScript types from it. This page covers how to work with it.
|
||||
|
||||
## Collections and fields
|
||||
|
||||
A **collection** is a type of content (posts, products, authors). Each collection has **fields** you define (a title, a body, a price). Every entry also has system fields EmDash manages for you.
|
||||
|
||||
You create and edit collections and fields visually in the admin panel under **Content Types**, or with the CLI. Changes take effect immediately, and a non-developer can do it.
|
||||
|
||||
## System fields
|
||||
|
||||
In addition to the fields you define, every entry has these, always present:
|
||||
|
||||
| Field | Purpose |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| `id` | Stable unique identifier |
|
||||
| `slug` | URL-safe identifier, unique per locale |
|
||||
| `status` | `draft`, `published`, or `scheduled` |
|
||||
| `author_id` | The user who created the entry |
|
||||
| `created_at` / `updated_at` / `published_at` | Timestamps |
|
||||
| `deleted_at` | Set on soft delete; the row is kept |
|
||||
| `version` | Increments on each save |
|
||||
|
||||
Deleting an entry is a soft delete: it can be restored.
|
||||
|
||||
## Changing the model anytime
|
||||
|
||||
You can add, rename, remove, or retype a field on a live collection at any time, through the admin panel or the CLI. Existing content is preserved.
|
||||
|
||||
<Aside type="tip">
|
||||
Adding a field is always safe. Removing one drops its data. Rename rather
|
||||
than remove-and-recreate when you want to keep existing values.
|
||||
</Aside>
|
||||
|
||||
## TypeScript types
|
||||
|
||||
Type generation is optional but recommended. Generate types from your current model:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
|
||||
This writes `.emdash/types.ts` with an interface per collection and typed query overloads, so `getEmDashCollection("posts")` returns fully typed entries:
|
||||
|
||||
```ts title=".emdash/types.ts (generated)"
|
||||
export interface Post {
|
||||
title: string;
|
||||
content: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
export function getEmDashCollection(
|
||||
type: "posts",
|
||||
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
|
||||
}
|
||||
```
|
||||
|
||||
Re-run the command after changing the model to keep types in sync.
|
||||
|
||||
## Workflows
|
||||
|
||||
Both workflows change the same model.
|
||||
|
||||
A non-developer uses the admin panel:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Open **Content Types** in the admin panel.
|
||||
2. Click **Add Collection**.
|
||||
3. Define fields with the visual builder.
|
||||
4. Start creating content.
|
||||
|
||||
</Steps>
|
||||
|
||||
A developer can use the CLI to generate types and move the model between environments:
|
||||
|
||||
```bash
|
||||
npx emdash types # generate TypeScript types
|
||||
npx emdash export-seed > seed.json # export the model as a seed file
|
||||
```
|
||||
|
||||
## Seed files
|
||||
|
||||
A seed file is a JSON description of collections, taxonomies, and menus. Templates ship one, and you can export your own for version control or to set up another environment.
|
||||
|
||||
```json title=".emdash/seed.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" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
|
||||
"menus": [{ "name": "primary", "label": "Primary Navigation" }]
|
||||
}
|
||||
```
|
||||
|
||||
Applying a seed is idempotent, so it is safe to re-run:
|
||||
|
||||
```ts
|
||||
import { applySeed, validateSeed } from "emdash/seed";
|
||||
import seedData from "./.emdash/seed.json";
|
||||
|
||||
const { valid, errors } = validateSeed(seedData);
|
||||
await applySeed(db, seedData, { includeContent: true, onConflict: "skip" });
|
||||
```
|
||||
|
||||
See [Seed file format](/themes/seed-files/) for the full schema.
|
||||
|
||||
## Next steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Collections" icon="document">
|
||||
Define [field types and validation](/concepts/collections/).
|
||||
</Card>
|
||||
<Card title="Admin Panel" icon="setting">
|
||||
See the [admin panel](/concepts/admin-panel/).
|
||||
</Card>
|
||||
<Card title="Seeding" icon="open-book">
|
||||
Set up sites with [seed files](/themes/seed-files/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,271 @@
|
||||
---
|
||||
title: Architecture (internals)
|
||||
description: How EmDash works under the hood, for people contributing to EmDash itself.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
This page is for people working **on** EmDash, not building a site with it. It documents internal mechanics — table layouts, the Astro integration, the request path, code generation. None of it is needed to use EmDash. If you are building a site, read [Architecture](/concepts/architecture/) and the [Content Model](/concepts/content-model/) instead.
|
||||
|
||||
## The Astro integration
|
||||
|
||||
EmDash runs as an Astro integration from the `emdash` package. At build time it:
|
||||
|
||||
- Injects the admin SPA and REST API routes with Astro's `injectRoute` API. Nothing is copied into the user's project. The injected paths are:
|
||||
|
||||
| Path pattern | Purpose |
|
||||
| ----------------------------------- | ------------------------------------- |
|
||||
| `/_emdash/admin/[...path]` | Admin panel SPA |
|
||||
| `/_emdash/api/manifest` | Admin manifest (collections, plugins) |
|
||||
| `/_emdash/api/content/[collection]` | Content entry CRUD |
|
||||
| `/_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 |
|
||||
|
||||
- Generates virtual modules so the bundler can resolve and tree-shake configuration and plugin code:
|
||||
|
||||
| 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 |
|
||||
|
||||
- Provides the Live Collections loader, manages migrations, and opens the storage connection.
|
||||
|
||||
## Database-first schema
|
||||
|
||||
Schema definitions live in the database, not in code. Two system tables track structure.
|
||||
|
||||
`_emdash_collections` holds one row per collection:
|
||||
|
||||
```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,
|
||||
supports JSON, -- ["drafts", "revisions", "preview"]
|
||||
source TEXT, -- how it was created
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
The `source` column records provenance: `manual` (admin UI), `template:<name>` (seed file), `import:wordpress` (importer), or `discovered` (auto-detected from existing tables).
|
||||
|
||||
`_emdash_fields` holds one row per field, linked to its collection:
|
||||
|
||||
```sql
|
||||
CREATE TABLE _emdash_fields (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_id TEXT REFERENCES _emdash_collections(id),
|
||||
slug TEXT NOT NULL, -- column name
|
||||
label TEXT NOT NULL,
|
||||
type TEXT NOT NULL, -- field type
|
||||
column_type TEXT NOT NULL, -- TEXT, REAL, INTEGER, JSON
|
||||
required INTEGER DEFAULT 0,
|
||||
unique_field INTEGER DEFAULT 0,
|
||||
default_value TEXT,
|
||||
validation JSON,
|
||||
widget TEXT,
|
||||
options JSON,
|
||||
sort_order INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, slug)
|
||||
);
|
||||
```
|
||||
|
||||
### Per-collection content tables
|
||||
|
||||
Each collection gets its own table, prefixed `ec_`. A `products` collection with `title` and `price` fields produces:
|
||||
|
||||
```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
|
||||
);
|
||||
```
|
||||
|
||||
Real columns (rather than one table with a JSON blob) give proper indexing, working foreign keys, a schema database tools can inspect, and no per-field JSON parsing.
|
||||
|
||||
The concerns stay separated:
|
||||
|
||||
| Concern | Location | Tables |
|
||||
| -------- | ------------------------ | --------------------------------------- |
|
||||
| Schema | System tables | `_emdash_collections`, `_emdash_fields` |
|
||||
| Content | Per-collection tables | `ec_posts`, `ec_products`, … |
|
||||
| Media | Separate table + storage | `media` table + R2/S3 |
|
||||
| Settings | Options table | `options` with a `site:` prefix |
|
||||
|
||||
### Runtime schema changes
|
||||
|
||||
Adding a field through the admin UI runs three steps:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Insert a record into `_emdash_fields`.
|
||||
2. Run `ALTER TABLE ec_<collection> ADD COLUMN <name> <TYPE>`.
|
||||
3. Regenerate the Zod schema used for validation.
|
||||
|
||||
</Steps>
|
||||
|
||||
SQLite supports add, rename, and drop column (drop requires SQLite 3.35+) at runtime. Changing a column's type is not supported in place, so EmDash rebuilds the table transparently: create a new table, copy rows, drop the old table, rename the new one.
|
||||
|
||||
### Runtime validation
|
||||
|
||||
EmDash builds Zod schemas from the field definitions at startup and validates every create and update against them:
|
||||
|
||||
```ts
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
## Data layer
|
||||
|
||||
EmDash uses [Kysely](https://kysely.dev) for type-safe SQL across every supported database (SQLite, libSQL, Cloudflare D1, and PostgreSQL). The dialect is selected by `virtual:emdash/dialect` from the configuration the site passes to the integration.
|
||||
|
||||
## Live Collections loader
|
||||
|
||||
Content is served at runtime through Astro's [Live Collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/). `emdashLoader()` implements Astro's `LiveLoader` interface and is registered as a single `_emdash` collection:
|
||||
|
||||
```ts title="src/live.config.ts"
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
```
|
||||
|
||||
The single `_emdash` collection wraps every content type; the loader filters by type when `getEmDashCollection("posts")` is called.
|
||||
|
||||
## Request paths
|
||||
|
||||
A content request from a page:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Astro receives the request and runs the page component.
|
||||
2. `getEmDashCollection()` calls Astro's `getLiveCollection()`.
|
||||
3. `emdashLoader` queries the relevant `ec_*` table through Kysely.
|
||||
4. Rows are mapped to Astro's entry format (`id`, `slug`, `data`).
|
||||
5. The component renders.
|
||||
|
||||
</Steps>
|
||||
|
||||
An admin request:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Middleware validates the session token.
|
||||
2. The API route runs CRUD through a repository.
|
||||
3. Lifecycle hooks fire (for example `content:beforeSave`).
|
||||
4. Kysely executes the SQL.
|
||||
5. The route returns JSON to the admin SPA.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Admin panel internals
|
||||
|
||||
The admin is one React island. Astro serves the shell and enforces authentication in middleware; everything inside is client-side, built on TanStack Router, TanStack Query, TanStack Table, React Hook Form + Zod, TipTap, and Kumo (Cloudflare's Base UI + Tailwind design system).
|
||||
|
||||
The shell route gates access in middleware:
|
||||
|
||||
```ts
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
### Manifest-driven UI
|
||||
|
||||
The admin hardcodes nothing about collections or plugins. It fetches `GET /_emdash/api/manifest`, which returns the collections, plugins, and taxonomies the requesting user may access, filtered by role:
|
||||
|
||||
```json
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Blog Posts",
|
||||
"icon": "file-text",
|
||||
"supports": ["drafts", "revisions", "preview"],
|
||||
"fields": [{ "slug": "title", "type": "string", "required": true }]
|
||||
}
|
||||
],
|
||||
"plugins": [{ "id": "audit-log", "label": "Audit Log" }],
|
||||
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
|
||||
"version": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
Navigation, forms, and field editors are generated from this manifest, so schema and plugin changes appear without an admin rebuild, and Zod schemas stay server-side.
|
||||
|
||||
### Plugin admin UIs
|
||||
|
||||
Plugin admin entry points are collected into a generated virtual module of static imports so the bundler can resolve and tree-shake them:
|
||||
|
||||
```ts title="virtual:emdash/plugin-admins (generated)"
|
||||
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
|
||||
|
||||
export const pluginAdmins = { seo: pluginAdmin0 };
|
||||
```
|
||||
|
||||
### Rich text conversion
|
||||
|
||||
Portable Text fields edit in TipTap (ProseMirror). Content is converted at the load and save boundaries by `portableTextToProsemirror()` and `prosemirrorToPortableText()`. Unknown blocks from plugins or imports are preserved as read-only placeholders.
|
||||
|
||||
### Signed uploads
|
||||
|
||||
Media uploads bypass Worker body-size limits with direct-to-storage signed URLs:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. The client requests an upload URL (`POST /api/media/upload-url`).
|
||||
2. The client uploads directly to the signed URL (R2 or S3).
|
||||
3. The client confirms (`POST /api/media/:id/confirm`).
|
||||
4. The server extracts metadata (dimensions, MIME type).
|
||||
|
||||
</Steps>
|
||||
|
||||
## Extending the content importer
|
||||
|
||||
The WordPress importer is built on a pluggable `ImportSource` interface. A custom source implements probe, analyze, and fetch:
|
||||
|
||||
```ts
|
||||
interface ImportSource {
|
||||
probe(input: ImportInput): Promise<ProbeResult>;
|
||||
analyze(input: ImportInput): Promise<AnalysisResult>;
|
||||
fetchContent(input: ImportInput): AsyncIterable<NormalizedEntry>;
|
||||
}
|
||||
```
|
||||
|
||||
`probe` validates the input and reports what it found, `analyze` maps source post types to EmDash collections and flags schema gaps, and `fetchContent` streams normalized entries the import pipeline writes through the same repositories the admin uses. Built-in sources cover WordPress WXR, WordPress.com, and the WordPress REST API; register a custom source to import from another system.
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
title: Documentation Style Guide
|
||||
description: The writing, structure, and code-sample conventions for EmDash documentation.
|
||||
---
|
||||
|
||||
This guide defines how EmDash documentation is written. Contributions are edited to match it. You do not need to memorize it — reviewers and editors will help — but following it makes a contribution merge faster.
|
||||
|
||||
Documentation exists to help someone do something, then get back to their project. Write for a reader who is tired, in a hurry, reading in a second language, or new to the stack. Serve that reader above all else.
|
||||
|
||||
## Readability
|
||||
|
||||
Prefer:
|
||||
|
||||
- Short sentences and short paragraphs.
|
||||
- Plain vocabulary over jargon.
|
||||
- Abbreviations and acronyms written out the first time.
|
||||
- Headings and lists to break up long passages.
|
||||
- Active voice.
|
||||
|
||||
Document **how to build with EmDash**, not **how EmDash is built**. Implementation detail belongs in docs only when it changes a decision the reader has to make (when to pick a non-default value, a caveat that affects their project). It never replaces a usage example.
|
||||
|
||||
For non-EmDash topics — TypeScript, the AT Protocol, web fonts, SQL — link to a reputable source instead of explaining them. Document what someone needs to know to use the feature **in EmDash**.
|
||||
|
||||
## What to emphasise
|
||||
|
||||
A page's emphasis must be set by what the reader needs to do their task, never by what was interesting or recent to the people who built EmDash. Three habits to actively resist:
|
||||
|
||||
- **Authoring-recency weight.** A decision being new, or fresh in the writer's mind, is not a reason to feature it. The most-changed thing is rarely the most important thing to a reader. Order sections, list items, and headlines by how often a reader needs them, not by when they were added. If you are documenting something because it just changed, you are probably writing a changelog entry, not a doc.
|
||||
|
||||
- **Builder-salience over reader-salience.** Internal architecture and design decisions that were significant to *make* are usually invisible and irrelevant to *use*. State the capability the reader gets, not the mechanism behind it. A reader defining a collection does not need to know where the schema is stored, any more than they need to know the parser's language. If the mechanism genuinely helps someone working on EmDash, it belongs in the internals doc, not in a user-facing page.
|
||||
|
||||
- **Straw-man self-definition.** Do not define EmDash by contrast with a caricature of other tools ("Unlike most CMSs…", "Traditional CMSs force you to…", "in many CMSs you declare X in code"). Describe what EmDash does, directly, and let it stand on its own. Comparison is allowed only when the comparison is *the reader's own question*: on the evaluation page and the "Coming from…" orientation pages. Even there it must be specific and fair — concrete behaviours and trade-offs, not a strawman the reader is invited to dislike.
|
||||
|
||||
- **Definition by negation.** Framing a capability as the work you _don't_ have to do — "no migration to write", "no rebuild", "without touching code", "no separate service" — is a straw-man in disguise: it only lands for a reader who is carrying the alternative you have invented for them. State what the reader _does_ and what _happens_. "Add a field in the admin panel; it takes effect immediately" — not "add a field with no migration, no rebuild, no code". The exception is a concrete, reader-relevant behaviour stated positively: "content is served at runtime, so edits appear immediately" is a fact about EmDash; "no rebuilds needed" is the same fact phrased as someone else's absent pain — prefer the first.
|
||||
|
||||
The test for any sentence: would a reader trying to finish their task be worse off if it were deleted? If not, delete it. If it only makes sense to a reader comparing EmDash to something else, it is in the wrong place or should be cut.
|
||||
|
||||
## Evergreen, not changelog
|
||||
|
||||
User-facing pages describe how EmDash works now, for a reader who has no prior version in their head. No "now", "no longer", "used to", "instead of the old", "this changed". Version-to-version differences live only in an [upgrade guide](#upgrade-and-migration-guides). A concept being recently introduced is never a reason to mention that it is recent.
|
||||
|
||||
## Voice and tone
|
||||
|
||||
Write neutral, factual sentences. State facts directly.
|
||||
|
||||
✅ Plugins run in an isolated runtime and can only reach the APIs they declare.
|
||||
|
||||
❌ Plugins live in a cosy little sandbox where nothing bad can ever happen!
|
||||
|
||||
- **Do not use _we_, _us_, _our_, or _let's_.** You are not sitting with the reader. Rephrase to address the reader directly or describe the system.
|
||||
- **Never use _I_.** Documentation is not about the author.
|
||||
- **Address the reader as _you_** when needed, especially to flag a step where something can go wrong.
|
||||
- **Do not narrate or tell a story.** No "now that we've set up X, let's move on to Y". Start a section with the goal, then the steps.
|
||||
- **Avoid whimsy, mascots, and cultural references.** They add reading effort and do not translate.
|
||||
- **Exclamation points are rare.** Use one only for something genuinely encouraging or surprising. When unsure, use a period.
|
||||
|
||||
## Headings
|
||||
|
||||
- Page title is the `<h1>` (from frontmatter `title`). Sections start at `<h2>`.
|
||||
- Keep headings short. `<h2>` and `<h3>` appear in the "On this page" sidebar; preview it and shorten anything that wraps.
|
||||
- No trailing punctuation, including a colon.
|
||||
- Format code as `<code>` in headings the same as in body text.
|
||||
|
||||
## Lists
|
||||
|
||||
- Use a bulleted list when order does not matter, such as a set of options or properties.
|
||||
- Use a numbered list for steps that must be followed in sequence. Use the Starlight `<Steps>` component for procedures.
|
||||
- When list items grow into multiple paragraphs or carry several code terms, switch to `<h3>` sections instead.
|
||||
|
||||
## Examples
|
||||
|
||||
- "for example" in full introduces a single example or hypothetical.
|
||||
- "e.g." inside parentheses introduces a non-exhaustive list (`e.g. GitHub, GitLab`).
|
||||
- A list that covers **every** option is not a list of examples — use parentheses without "e.g." (`the required properties (src, alt)`).
|
||||
|
||||
## Code samples
|
||||
|
||||
Code samples are as important as the prose around them.
|
||||
|
||||
**Introduce every code block with a full, standalone sentence** on its own line, telling the reader what the block does. Do not lead in with a sentence fragment ending in a colon, a bare heading, or "like so:".
|
||||
|
||||
✅ The following example registers a plugin in the `sandboxed` array:
|
||||
|
||||
❌ Add the plugin like so:
|
||||
|
||||
The introduction primes the reader for what the code does, so they only need to work out _how_. It also creates a fill-in-the-blank pattern for a reader doing something slightly different.
|
||||
|
||||
Within a `<Steps>` procedure, a direct imperative instruction is the introduction ("Add a `tsconfig.json`:" followed by the file is fine in a numbered step).
|
||||
|
||||
Other rules:
|
||||
|
||||
- **Use real, working code.** No `foo`/`bar`. Show one realistic configuration, not every possible value — the reader will only have one.
|
||||
- **Add a `title=` filename** to any block that represents a file, so the reader knows where the code goes.
|
||||
|
||||
````md
|
||||
```ts title="src/plugin.ts"
|
||||
````
|
||||
|
||||
- **Use Expressive Code annotations**, not raw ` ```diff ` fences, for before/after changes. Mark changed lines with `del={n}` / `ins={n}` or changed text with `del="…"` / `ins="…"`. Keep diffs minimal and local to the lines that change.
|
||||
|
||||
The following example shows a single line changing:
|
||||
|
||||
````md
|
||||
```ts del={1} ins={2}
|
||||
import { definePlugin } from "emdash";
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
```
|
||||
````
|
||||
|
||||
- Preview rendered code locally before submitting. A typo can break the display.
|
||||
|
||||
## Upgrade and migration guides
|
||||
|
||||
A guide that helps a reader move an existing project to a new version follows a fixed structure. The "What should I do?" sections are the part readers value most — do not skimp on them.
|
||||
|
||||
Open with: how to upgrade, a note that things may "just work" but to read on if not, and a link to the changelog.
|
||||
|
||||
Then list each breaking change as its own entry:
|
||||
|
||||
```md
|
||||
### [Renamed/Changed/Removed/Deprecated]: <feature>
|
||||
|
||||
In earlier versions, <one sentence, past tense, what it did>.
|
||||
|
||||
<One sentence, present tense, how it works now>.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
<Imperative actions: Update… / Replace… / Remove…, with a minimal diff.>
|
||||
```
|
||||
|
||||
Choose the verb by how the reader feels the impact. If a new default replaces their value, that is a "Changed: default value", not an "Added: option".
|
||||
|
||||
A breaking change is one that requires a change to the reader's project or it stops working. Give the action, not just the fact. Not "the minimum Node.js version is now X" but "check your Node.js version with the following command, and upgrade if it is below X".
|
||||
|
||||
## EmDash specifics
|
||||
|
||||
Conventions for recurring situations, ordered by how often a contributor hits them. This is a list of conventions, not a ranking of which features matter.
|
||||
|
||||
### Plugins: sandboxed vs native
|
||||
|
||||
Sandboxed and native plugins are different formats with different authoring shapes. A change to one rarely affects the other. State which format a page or example is about. When editing a sandboxed-plugin page, do not change native-plugin examples, and the reverse.
|
||||
|
||||
### Localization
|
||||
|
||||
Do not include `messages.po` changes in a docs PR. A workflow extracts catalogs on merge to `main`. Including them creates churn and merge conflicts.
|
||||
|
||||
### Experimental features
|
||||
|
||||
A feature behind an experimental flag, or an unstable wire format under an RFC, can change without notice. Keep its documentation lean, flag it with a caution `<Aside>`, and point to the RFC or discussion as the source of truth. Do not document an unstable surface in exhaustive detail.
|
||||
|
||||
### Atmosphere accounts
|
||||
|
||||
When the portable, user-owned identity behind Bluesky and the wider AT Protocol network comes up, call it an **Atmosphere account** and use that term consistently. Link the first mention to [the Atmosphere login guide](/guides/atmosphere-auth/) or [atmosphereaccount.com](https://atmosphereaccount.com). `did:plc:…` and handles are its concrete identifiers; use them where a literal value is needed.
|
||||
|
||||
## Docs are code
|
||||
|
||||
The documentation site is an EmDash-adjacent Astro project. Documentation changes go through the same pull request and review flow as code. Every text change waits for a review; a wording change can shift the meaning of a sentence or need matching edits elsewhere on the site. Small, reviewed, consistent changes keep the whole site coherent.
|
||||
@@ -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 Gets Accepted
|
||||
|
||||
| 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).
|
||||
@@ -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
|
||||
|
||||
Every translation must be supervised by a **native or fluent speaker**. AI-generated translations are accepted, but only when a fluent speaker reviews every string and previews it in the running admin panel before submitting. Unsupervised machine output is not accepted. See [AI-assisted translations](#ai-assisted-translations) and [Testing your translations](#testing-your-translations) below.
|
||||
|
||||
Leaving a string untranslated is better than translating it wrongly. A wrong translation misleads users; the English fallback only inconveniences them.
|
||||
|
||||
## 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 — a maintainer enables 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 generate translations with AI tools, including a full first pass, but a fluent speaker must supervise the result:
|
||||
|
||||
- A fluent speaker **must** review every string. AI tools make subtle errors only a fluent speaker catches — wrong register, unnatural phrasing, incorrect technical terms.
|
||||
- A fluent speaker **must** preview the translations 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 unsupervised 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.
|
||||
@@ -0,0 +1,365 @@
|
||||
---
|
||||
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`)
|
||||
|
||||
## Configure Bindings
|
||||
|
||||
Create `wrangler.jsonc` in your project root with D1 and R2 bindings. Wrangler provisions both resources on the first deploy if they don't already exist.
|
||||
|
||||
```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",
|
||||
},
|
||||
],
|
||||
|
||||
"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" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## First Boot
|
||||
|
||||
Database migrations run automatically on the first request after deployment, and on every subsequent boot if there's anything new to apply.
|
||||
|
||||
If the database is empty (no collections) and the setup wizard hasn't been completed, EmDash also applies a seed file on first boot. The seed is read at build time from `.emdash/seed.json`, the path in `package.json#emdash.seed`, or `seed/seed.json` — whichever is found first — and inlined into the bundle. If none is present, a built-in default seed is used. Subsequent deploys against an existing database leave its content alone.
|
||||
|
||||
To change the schema or content model of a site that is already deployed, see [Evolving a Deployed Site](/deployment/schema-evolution/).
|
||||
|
||||
## Scheduled Publishing
|
||||
|
||||
On Cloudflare Workers, scheduled publishing, plugin cron, and maintenance tasks run from a Worker Cron Trigger. New Cloudflare templates include this setup automatically. If you are adding updating an existing project, export the EmDash Worker entry from `@emdash-cms/cloudflare/worker`:
|
||||
|
||||
```ts title="src/worker.ts"
|
||||
export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
|
||||
```
|
||||
|
||||
Then add a Cron Trigger to `wrangler.jsonc`:
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"triggers": {
|
||||
"crons": ["* * * * *"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Without the Cron Trigger, content scheduled in the admin panel does not publish on Cloudflare Workers, and plugin cron jobs do not run. Local `astro dev` still uses the in-process scheduler.
|
||||
</Aside>
|
||||
|
||||
## 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.
|
||||
|
||||
<Aside type="caution">
|
||||
Read replica sessions are incompatible with the
|
||||
[`global_fetch_strictly_public`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)
|
||||
compatibility flag. With that flag in your `wrangler.jsonc`, the internal request the D1 Sessions
|
||||
API makes to route queries to replicas is silently blocked, and every SSR request hangs until the
|
||||
Worker is killed — with nothing in the logs (`outcome: "canceled"`, no exceptions). The hang may
|
||||
only begin once replicas finish provisioning, so it can pass an initial post-deploy check and
|
||||
surface hours later. If your Worker needs that flag (for example, to fetch its own origin), keep
|
||||
`session` disabled. See [#1273](https://github.com/emdash-cms/emdash/issues/1273).
|
||||
</Aside>
|
||||
|
||||
See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.
|
||||
|
||||
## Object Cache
|
||||
|
||||
To reduce read load on D1, cache content and configuration query results in Cloudflare KV. Reads are served from KV instead of querying the database on every request:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
|
||||
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
objectCache: kvCache({ binding: "CACHE" }),
|
||||
}),
|
||||
```
|
||||
|
||||
See [Object Cache](/deployment/object-cache/) for KV setup, options, and invalidation behavior.
|
||||
|
||||
## Workers Cache
|
||||
|
||||
Cloudflare's [Workers Cache](https://developers.cloudflare.com/workers/cache/) (`"cache": { "enabled": true }` in `wrangler.jsonc`) puts an edge cache **in front of** your Worker: matching requests are served without running your Worker at all. This works well with EmDash:
|
||||
|
||||
- EmDash admin and API responses send `Cache-Control: private, no-store` and are never stored.
|
||||
- Your public pages control their own caching through the `Cache-Control` headers they return.
|
||||
|
||||
Two things to know before enabling it:
|
||||
|
||||
1. **Responses without a `Cache-Control` header are still cached.** Workers Cache applies RFC 9111 heuristic freshness — a `200` without any header is cached for 2 hours. Give every custom route an explicit `Cache-Control` (use `private, no-store` for anything session-dependent).
|
||||
2. **Cached pages are shared with logged-in editors.** The cache runs before your Worker, so it cannot bypass based on request cookies. A logged-in editor may receive the cached anonymous variant of a public page — without the visual editing toolbar — until the entry expires. Editor-rendered responses themselves are never stored (they carry `private, no-store`), so nothing leaks in the other direction.
|
||||
|
||||
## 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 the authentication provider instead of passkeys, giving single sign-on through your existing identity provider. The following configuration enables it:
|
||||
|
||||
```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.
|
||||
|
||||
## Email
|
||||
|
||||
On Workers, the only built-in `email:deliver` handler is a dev console stub, so
|
||||
email-dependent flows — magic-link login, team invites, and comment
|
||||
notifications — fail with **"Email is not configured"** in production. The
|
||||
`cloudflareEmail()` plugin delivers real email through
|
||||
[Cloudflare Email Sending](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/)
|
||||
using a native `send_email` Worker binding, with no external API keys.
|
||||
|
||||
### 1. Onboard a sender domain
|
||||
|
||||
In the Cloudflare dashboard, go to **Email** and verify the domain (or address)
|
||||
you send from. Email Sending rejects messages from unverified senders.
|
||||
|
||||
### 2. Add the binding
|
||||
|
||||
Declare a `send_email` binding in `wrangler.jsonc`:
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"send_email": [{ "name": "EMAIL" }],
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register the provider
|
||||
|
||||
Add the plugin to your `emdash()` integration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { d1, r2 } from "@emdash-cms/cloudflare";
|
||||
import { cloudflareEmail } from "@emdash-cms/cloudflare/plugins";
|
||||
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
plugins: [
|
||||
cloudflareEmail({
|
||||
from: { email: "cms@mails.example.com", name: "My Site CMS" },
|
||||
replyTo: "hello@example.com", // optional
|
||||
binding: "EMAIL", // optional, defaults to "EMAIL"
|
||||
}),
|
||||
],
|
||||
}),
|
||||
```
|
||||
|
||||
### 4. Activate and select it
|
||||
|
||||
Deploy, then activate the plugin under **Admin → Extensions** and choose it as
|
||||
the provider under **Settings → Email**.
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------- | ---------------------------- | ------------ | --------------------------------------------------------------------- |
|
||||
| `from` | `string \| { email, name? }` | — (required) | Sender address on a domain onboarded for Email Sending. |
|
||||
| `replyTo` | `string` | — | Optional Reply-To, useful when `from` is a no-reply subdomain address. |
|
||||
| `binding` | `string` | `"EMAIL"` | Name of the `send_email` binding in `wrangler.jsonc`. |
|
||||
|
||||
<Aside type="note">
|
||||
BCC and attachments are out of scope (not modeled in EmDash's `EmailMessage`),
|
||||
and the provider is never auto-selected — you must choose it under Settings →
|
||||
Email.
|
||||
</Aside>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Recommended: encryption key
|
||||
|
||||
`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
|
||||
rest (webhook tokens, Turnstile keys, etc.). The key is validated on
|
||||
startup; plugin secret encryption uses it once enabled. Set it on
|
||||
every deployment so secrets are protected without a later config
|
||||
change.
|
||||
|
||||
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.
|
||||
|
||||
Generate a key and store it as a Worker secret with the following commands:
|
||||
|
||||
```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` | Optional. If set, it is used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |
|
||||
|
||||
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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
If you see schema errors, tail the Worker logs (`wrangler tail`) and reproduce the error to capture the underlying message — then file an issue with that output.
|
||||
@@ -0,0 +1,436 @@
|
||||
---
|
||||
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 |
|
||||
| **Hyperdrive** | PostgreSQL on Cloudflare Workers | Edge, existing Postgres |
|
||||
| **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"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="wrangler.toml">
|
||||
```toml
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "emdash-db"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside>
|
||||
EmDash runs migrations on D1 automatically on the first request after deployment. Wrangler
|
||||
provisions the D1 database itself on first deploy if it doesn't already exist.
|
||||
</Aside>
|
||||
|
||||
### 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 type="caution">
|
||||
Sessions are incompatible with the
|
||||
[`global_fetch_strictly_public`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#global-fetch-strictly-public)
|
||||
compatibility flag: the internal request the D1 Sessions API makes to route queries to replicas
|
||||
is silently blocked, and every SSR request hangs with nothing in the logs. The hang may only
|
||||
begin once replicas finish provisioning, so it can pass an initial post-deploy check. If your
|
||||
Worker needs that flag, keep `session` disabled. See
|
||||
[#1273](https://github.com/emdash-cms/emdash/issues/1273).
|
||||
</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>
|
||||
|
||||
## Hyperdrive
|
||||
|
||||
Use the `hyperdrive()` adapter to run EmDash on **Cloudflare Workers** backed by an existing PostgreSQL — or Postgres-compatible (e.g. PlanetScale Postgres) — database. [Hyperdrive](https://developers.cloudflare.com/hyperdrive/) pools and accelerates the connection over Cloudflare's network; EmDash's PostgreSQL dialect runs the queries.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { hyperdrive, r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: hyperdrive({ binding: "HYPERDRIVE" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- `pg >= 8.16.3` installed in your site (`pnpm add pg`)
|
||||
- `compatibility_flags: ["nodejs_compat"]`
|
||||
- `compatibility_date >= "2024-09-23"`
|
||||
|
||||
### Setup
|
||||
|
||||
Create the Hyperdrive configuration and add the binding to your Wrangler config:
|
||||
|
||||
```sh
|
||||
wrangler hyperdrive create emdash-db \
|
||||
--connection-string "postgres://user:password@host/db?sslmode=verify-full" \
|
||||
--caching-disabled
|
||||
```
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="wrangler.jsonc">
|
||||
```jsonc
|
||||
{
|
||||
"hyperdrive": [
|
||||
{
|
||||
"binding": "HYPERDRIVE",
|
||||
"id": "<your-hyperdrive-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="wrangler.toml">
|
||||
```toml
|
||||
[[hyperdrive]]
|
||||
binding = "HYPERDRIVE"
|
||||
id = "<your-hyperdrive-id>"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside type="caution" title="Disable query caching on the primary binding">
|
||||
Hyperdrive's query cache is **on by default and must be turned off** for the primary EmDash binding. EmDash runs its own caching layer and relies on read-after-write consistency — the admin and setup wizard write a row and immediately read it back. With caching on, Hyperdrive can serve the pre-write result within its TTL, which corrupts setup (e.g. "collection already exists", then half-created tables) and shows editors stale content. Disable it:
|
||||
|
||||
```sh
|
||||
wrangler hyperdrive update <id> --caching-disabled
|
||||
# or at create time: wrangler hyperdrive create ... --caching-disabled
|
||||
```
|
||||
|
||||
</Aside>
|
||||
|
||||
<Aside type="tip" title="Co-locate the Worker with your database">
|
||||
The request path makes multiple round trips, so pair the deploy with a [Smart Placement](https://developers.cloudflare.com/workers/configuration/smart-placement/) hint to run the Worker in the Cloudflare data center closest to your database's region:
|
||||
|
||||
```jsonc
|
||||
// wrangler.jsonc
|
||||
{ "placement": { "region": "aws:us-east-1" } }
|
||||
```
|
||||
|
||||
</Aside>
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------- | -------------------------------------------------------------------- |
|
||||
| `binding` | `string` | `"HYPERDRIVE"` | Primary (caching-disabled) Hyperdrive binding name |
|
||||
| `cachedBinding` | `string` | — | Optional caching-enabled binding for anonymous reads (see below) |
|
||||
| `max` | `number` | `5` | Max size of the in-Worker connection pool to Hyperdrive |
|
||||
|
||||
### Serving anonymous reads from cache
|
||||
|
||||
By default you disable Hyperdrive caching entirely, because the admin and writes need read-after-write consistency. But **anonymous public reads** — no session, no write — can tolerate a short staleness window. If that trade-off is acceptable, run **two Hyperdrive configurations over the same database**: one with caching off (the primary `binding`) and one with caching on (`cachedBinding`). EmDash then routes anonymous read requests through the cache-enabled binding while every authenticated request and every write stays on the uncached primary, so read-after-write consistency is preserved.
|
||||
|
||||
```sh
|
||||
# Primary — caching OFF (used by admin, auth'd requests, writes, migrations)
|
||||
wrangler hyperdrive create emdash-db \
|
||||
--connection-string "postgres://user:password@host/db?sslmode=verify-full" \
|
||||
--caching-disabled
|
||||
|
||||
# Cached — SAME connection string, caching ON (used by anonymous reads only)
|
||||
wrangler hyperdrive create emdash-db-cached \
|
||||
--connection-string "postgres://user:password@host/db?sslmode=verify-full"
|
||||
```
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"hyperdrive": [
|
||||
{ "binding": "HYPERDRIVE", "id": "<caching-disabled-id>" },
|
||||
{ "binding": "HYPERDRIVE_CACHED", "id": "<caching-enabled-id>" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
database: hyperdrive({ binding: "HYPERDRIVE", cachedBinding: "HYPERDRIVE_CACHED" });
|
||||
```
|
||||
|
||||
This is the [two-configuration pattern](https://developers.cloudflare.com/hyperdrive/configuration/query-caching/#disable-caching) Cloudflare documents for caching. EmDash decides which binding to use per request:
|
||||
|
||||
- **Anonymous reads of public-site paths** (`GET`/`HEAD`, no session, not under `/_emdash`) → cache-enabled `cachedBinding`.
|
||||
- **Authenticated requests** (editors, authors) → uncached `binding`.
|
||||
- **Writes** (`POST`, `PUT`, `DELETE`, including anonymous ones) → uncached `binding`.
|
||||
- **Any request under `/_emdash`** (admin, setup, auth, internal APIs), even an anonymous `GET` → uncached `binding`.
|
||||
- **Migrations and cold-start** → always the primary `binding`.
|
||||
|
||||
<Aside>
|
||||
The `/_emdash` carve-out matters: the setup wizard and login-state checks are
|
||||
anonymous `GET`s that read a row written moments earlier. Caching them would
|
||||
loop the setup wizard or show stale auth state, so they always use the
|
||||
uncached primary.
|
||||
</Aside>
|
||||
|
||||
<Aside type="caution">
|
||||
Both configurations must point at the **same** database, or anonymous visitors see a different dataset than editors. Anonymous reads of just-published content can be up to the cache's `max_age` stale (Hyperdrive default 60s, max 1h), and this cache is independent of EmDash's own cache invalidation. Only opt in if a short public-read staleness window is acceptable; otherwise omit `cachedBinding` and keep caching disabled.
|
||||
</Aside>
|
||||
|
||||
<Aside>
|
||||
**Sandboxed plugins are D1-only.** The sandbox plugin bridge talks to a D1 binding directly, independent of the configured adapter, so sandboxed plugins aren't available on a Hyperdrive deployment.
|
||||
</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 runs migrations automatically on the first request, for every supported dialect (D1, SQLite, libSQL, PostgreSQL). Migrations are bundled with the `emdash` package and embedded into your build.
|
||||
|
||||
If the database is empty (no collections) and the setup wizard has not been completed, EmDash also applies a seed file on first boot. The seed is read from `.emdash/seed.json`, the path in `package.json#emdash.seed`, or `seed/seed.json` — whichever is found first — and inlined into the build at compile time. If none is present, a built-in default seed is used. Subsequent boots against an existing database leave its content alone.
|
||||
|
||||
## 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 })],
|
||||
});
|
||||
```
|
||||
|
||||
The choice can also key off an environment variable instead of the build mode:
|
||||
|
||||
```js
|
||||
const database = process.env.DATABASE_URL
|
||||
? postgres({ connectionString: process.env.DATABASE_URL })
|
||||
: sqlite({ url: "file:./data.db" });
|
||||
```
|
||||
@@ -0,0 +1,221 @@
|
||||
---
|
||||
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 uses SQLite with local or S3-compatible storage; libSQL and PostgreSQL work the same way on Node.js — see [Database Options](/deployment/database/).
|
||||
|
||||
## 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. Start the server:
|
||||
|
||||
```bash
|
||||
node ./dist/server/entry.mjs
|
||||
```
|
||||
|
||||
The server runs on `http://localhost:4321` by default. Migrations are applied on the first request. If the database is empty and setup hasn't been completed, your seed file (or the built-in default if you don't have one) is also applied on that first request.
|
||||
|
||||
## 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 ./
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
```
|
||||
|
||||
The seed file is read at build time and inlined into the bundle, so it does not need to be copied into the runtime image. Migrations run on the first request after a deploy; the seed applies only when the database has no collections and setup hasn't been completed — existing data is never overwritten.
|
||||
|
||||
Build the image and run the container:
|
||||
|
||||
```bash
|
||||
docker build -t my-emdash-site .
|
||||
docker run -p 4321:4321 -v emdash-data:/app/data my-emdash-site
|
||||
```
|
||||
|
||||
A Docker Compose file manages the same container with a named volume:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
emdash:
|
||||
build: .
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- emdash-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
emdash-data:
|
||||
```
|
||||
|
||||
Start the stack in the background:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Recommended: encryption key
|
||||
|
||||
`EMDASH_ENCRYPTION_KEY` is the key for encrypting plugin secrets at
|
||||
rest. The key is validated on startup; plugin secret encryption uses
|
||||
it once enabled. Set it on every deployment so secrets are protected
|
||||
without a later config change.
|
||||
|
||||
Generate a key and add the result to your environment:
|
||||
|
||||
```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` | Optional. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which takes precedence), keeping commenter-IP hashes stable for installs that already rely on it. Leave it unset for a new deployment. |
|
||||
|
||||
### 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.
|
||||
@@ -0,0 +1,130 @@
|
||||
---
|
||||
title: Object Cache
|
||||
description: Cache query results in Cloudflare KV or memory to serve reads without querying the database on every request.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash reads content and site configuration from the database on every request. The object cache stores those query results in a fast key/value store, so repeat requests are served from the cache instead of the database. It reduces read load on the database — useful on Cloudflare, where KV serves far more requests per second than D1.
|
||||
|
||||
The object cache is optional and disabled by default. Enable it by adding an `objectCache` adapter to the `emdash()` integration.
|
||||
|
||||
## Overview
|
||||
|
||||
| Backend | Best for | Shared across isolates |
|
||||
| ---------- | --------------------------------- | ---------------------- |
|
||||
| **KV** | Cloudflare Workers | Yes |
|
||||
| **Memory** | Node.js, local development | No (per process) |
|
||||
|
||||
On Cloudflare, requests are served by many short-lived isolates across regions. KV is shared by all of them, so a value cached by one request is available to the next, anywhere. The memory backend caches within a single process, which suits a long-running Node.js server.
|
||||
|
||||
## Cloudflare KV
|
||||
|
||||
Configure the KV adapter and point it at a KV binding:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash from "emdash/astro";
|
||||
import { d1, r2, kvCache } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
objectCache: kvCache({ binding: "CACHE" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Setup
|
||||
|
||||
Create a KV namespace and add the binding to your Wrangler configuration.
|
||||
|
||||
```sh
|
||||
npx wrangler kv namespace create CACHE
|
||||
```
|
||||
|
||||
The command prints a namespace `id`. Add it under the binding name used in `kvCache`:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="wrangler.jsonc">
|
||||
```jsonc
|
||||
{
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "CACHE",
|
||||
"id": "<namespace-id>"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="wrangler.toml">
|
||||
```toml
|
||||
[[kv_namespaces]]
|
||||
binding = "CACHE"
|
||||
id = "<namespace-id>"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------ | -------- | -------- | --------------------------------------------------------------------------------- |
|
||||
| `binding` | `string` | — | KV binding name from your Wrangler configuration. Required. |
|
||||
| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. KV enforces a 60-second minimum. |
|
||||
| `revalidate` | `number` | `1000` | Isolate-local epoch-reuse window, in milliseconds. See [Freshness](#freshness). |
|
||||
| `timeout` | `number` | `2000` | Maximum time, in milliseconds, to wait for a KV operation before treating it as a cache miss. Guards against a stalled KV read hanging the request. Set to `0` to disable. |
|
||||
| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. Set a unique value when several sites share one namespace. |
|
||||
|
||||
## Node.js (memory)
|
||||
|
||||
The memory adapter caches within the server process. It needs no external service:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash, { memoryCache } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
objectCache: memoryCache(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------ | -------- | ------- | ------------------------------------------------------ |
|
||||
| `defaultTtl` | `number` | `3600` | Time-to-live for cached entries, in seconds. |
|
||||
| `revalidate` | `number` | `1000` | Isolate-local epoch-reuse window, in ms.|
|
||||
| `maxEntries` | `number` | `1000` | Maximum number of cached keys before older keys evict. |
|
||||
| `keyPrefix` | `string` | `"em"` | Prefix for every cache key. |
|
||||
|
||||
## What gets cached
|
||||
|
||||
The object cache covers the reads that run on a typical page render:
|
||||
|
||||
- Content queries: `getEmDashCollection`, `getEmDashEntry`, and `resolveEmDashPath`.
|
||||
- Site settings, navigation menus, and taxonomy terms.
|
||||
|
||||
Admin API requests, media files, and full HTML responses are not handled here. To cache rendered HTML at the edge, see [Deploy to Cloudflare](/deployment/cloudflare/).
|
||||
|
||||
## Freshness
|
||||
|
||||
Editing content through the admin panel or the REST API invalidates the affected cache entries automatically. Creating, updating, publishing, or deleting an entry clears the cached queries for its collection; changing a byline or taxonomy term clears the entries that display it.
|
||||
|
||||
<Aside type="note">
|
||||
Preview links and visual editing bypass the object cache, so editors previewing see the current content immediately. Other requests — including authenticated browsing outside edit mode — are served from the cache, which only ever stores published content.
|
||||
</Aside>
|
||||
|
||||
For anonymous visitors, a change takes time to appear across all isolates as they pick up the bumped epoch. With the in-isolate memory backend this is immediate. With Workers KV it is bounded by KV's edge-cache propagation (eventual consistency, up to ~60 seconds) plus the isolate-local `revalidate` window (default one second). Lower `revalidate` for faster local propagation at the cost of more reads against the cache; raise it to read the cache less often.
|
||||
|
||||
### Scheduled content
|
||||
|
||||
Scheduled entries become visible when their publish time passes. A cached page reflects a newly-published scheduled entry on the next change to its collection, or when the cached entry's `defaultTtl` lapses. If precise scheduled publishing matters for your site, set a lower `defaultTtl`.
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
title: Evolving a Deployed Site
|
||||
description: Change the schema and content model of a live EmDash site, and keep your seed file in sync.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash stores collections, fields, and taxonomies in the database, next to the content itself. Deploying new code does not change them: a deploy replaces the Worker (or server) code, while the schema and content in the database stay as they are. This page explains how to change the content model of a site that is already deployed, using Cloudflare D1 as the running example. The same workflows apply to the other [database options](/deployment/database/).
|
||||
|
||||
## What changes what
|
||||
|
||||
A site goes through four distinct workflows. Each one touches a different layer:
|
||||
|
||||
| Workflow | What changes | How |
|
||||
| ------------------------------ | ---------------------------------- | ---------------------------------------------------------------------- |
|
||||
| Content editing | Entries, media, settings | Admin panel or [content API](/reference/api/) |
|
||||
| Code deploy | Templates, config, EmDash version | `wrangler deploy` — leaves schema and content untouched |
|
||||
| First-time bootstrap | Everything, from empty | Migrations + seed file + setup wizard, automatic on first boot |
|
||||
| Schema evolution | Collections, fields, taxonomies | Admin panel or `emdash schema` against the live site (this page) |
|
||||
|
||||
The seed file only participates in the third row. It is applied once, when the database is empty and the setup wizard has not been completed. Deploying a changed seed file against an existing database does nothing — evolving a live site's schema always happens through the admin panel or the API.
|
||||
|
||||
<Aside type="note">
|
||||
EmDash's internal tables (users, revisions, redirects, options) are migrated automatically: pending
|
||||
migrations run on the first request after a deploy. Your content model is never changed by a
|
||||
migration — it belongs to you.
|
||||
</Aside>
|
||||
|
||||
## Change the schema in the admin panel
|
||||
|
||||
The admin panel is the primary way to evolve a deployed site. Open **Content Types** in the admin and add, edit, or remove collections and fields. Changes take effect immediately — the content API, the loader, and the editing UI all read the schema from the database at runtime.
|
||||
|
||||
See [Collections & Fields](/concepts/collections/) for the available field types, validation rules, and widget options.
|
||||
|
||||
After changing the schema, regenerate the TypeScript types your templates use. The `emdash types` command reads the schema from a running instance, so it can point at the deployed site directly:
|
||||
|
||||
```bash
|
||||
npx emdash types --url https://example.com
|
||||
```
|
||||
|
||||
## Change the schema from the CLI
|
||||
|
||||
The `emdash schema` commands talk to a running instance over its REST API, so they work against a deployed site the same way they work against local dev. Authenticate once with the device flow:
|
||||
|
||||
```bash
|
||||
npx emdash login --url https://example.com
|
||||
```
|
||||
|
||||
Alternatively, create an API token in the admin under **Settings → API Tokens** and pass it with `--token` or the `EMDASH_TOKEN` environment variable — useful for CI.
|
||||
|
||||
Then evolve the schema with the same commands you would use locally:
|
||||
|
||||
```bash
|
||||
npx emdash schema add-field posts subtitle --type string --label "Subtitle" --url https://example.com
|
||||
npx emdash schema remove-field posts legacy_field --url https://example.com
|
||||
npx emdash schema create projects --label Projects --url https://example.com
|
||||
```
|
||||
|
||||
Because these commands are plain CLI calls, they can be scripted: a repeatable "migration" for your content model is a shell script of `emdash schema` calls, checked into your repository and run against each environment in turn.
|
||||
|
||||
See the [CLI reference](/reference/cli/#emdash-schema) for the full command list.
|
||||
|
||||
<Aside type="caution">
|
||||
Removing a field deletes its column and data. Test schema changes against a preview environment
|
||||
before running them against production — see [Rehearse changes on a preview
|
||||
environment](#rehearse-changes-on-a-preview-environment).
|
||||
</Aside>
|
||||
|
||||
## Keep the seed file in sync
|
||||
|
||||
The seed file embedded in your build determines what a **fresh** database initializes to: a new preview environment, a disaster-recovery rebuild, or a second deployment of the same site. If the seed still describes the starter blog while production has evolved into something else, every fresh environment bootstraps with the wrong model.
|
||||
|
||||
The build embeds the first seed file found at `.emdash/seed.json`, the path in `package.json#emdash.seed`, or `seed/seed.json`. If none is present, a built-in default seed (the starter blog model) is embedded, and `astro dev` logs a warning.
|
||||
|
||||
After evolving a deployed site's schema, export the live model back into your repository. `emdash export-seed` reads a local SQLite file, and `wrangler d1 export` produces one from the deployed D1 database:
|
||||
|
||||
```bash
|
||||
npx wrangler d1 export emdash-db --remote --output=./prod.sql
|
||||
sqlite3 prod.db < prod.sql
|
||||
npx emdash export-seed --database prod.db > .emdash/seed.json
|
||||
```
|
||||
|
||||
The exported seed contains the settings, collections, taxonomies, menus, and widget areas of the live site. Add `--with-content` to include entries. Commit the updated `.emdash/seed.json` together with the code that depends on the new schema, so a fresh environment always bootstraps to a model the code understands.
|
||||
|
||||
## Rehearse changes on a preview environment
|
||||
|
||||
A destructive schema change (removing a field, restructuring a collection) is safest rehearsed against a disposable copy of production.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Add a preview environment with its own D1 database to `wrangler.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"env": {
|
||||
"preview": {
|
||||
"d1_databases": [{ "binding": "DB", "database_name": "emdash-db-preview" }],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
2. Copy production into it:
|
||||
|
||||
```bash
|
||||
npx wrangler d1 export emdash-db --remote --output=./prod.sql
|
||||
npx wrangler d1 execute emdash-db-preview --remote --file=./prod.sql
|
||||
```
|
||||
|
||||
3. Deploy and run the schema change against the preview URL:
|
||||
|
||||
```bash
|
||||
npx wrangler deploy --env preview
|
||||
npx emdash schema remove-field posts legacy_field --url https://preview.example.com
|
||||
```
|
||||
|
||||
4. Verify the site renders and the admin behaves as expected, then run the same commands against production.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Recover from a wrong turn
|
||||
|
||||
- **A field was removed by mistake.** The column and its data are gone from the live database. Restore from a D1 [Time Travel](https://developers.cloudflare.com/d1/reference/time-travel/) point-in-time backup, or re-add the field and restore its values from an earlier `wrangler d1 export`.
|
||||
- **A fresh environment bootstrapped with the wrong model.** The embedded seed was stale or missing. Update `.emdash/seed.json` (see [Keep the seed file in sync](#keep-the-seed-file-in-sync)), rebuild, and point the deploy at an empty database to bootstrap again.
|
||||
- **The schema and the templates disagree.** Deploys and schema changes are independent, so order them deliberately: additive schema changes (new collection, new optional field) go first, then the code that uses them. For removals, deploy the code that stops using the field first, then remove the field.
|
||||
@@ -0,0 +1,316 @@
|
||||
---
|
||||
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>
|
||||
|
||||
The following configuration points EmDash at an S3-compatible bucket:
|
||||
|
||||
```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.
|
||||
|
||||
Calling `s3()` with no arguments reads every field from the `S3_*` environment variables:
|
||||
|
||||
```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
|
||||
|
||||
Point the S3 adapter at a MinIO endpoint with its access credentials:
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
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 server is reachable at the following endpoint, with no authentication or API key. It is public and read-only.
|
||||
|
||||
```
|
||||
https://docs.emdashcms.com/mcp
|
||||
```
|
||||
|
||||
### 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 |
|
||||
|
||||
Open the project and accept the workspace-trust prompt the tool shows on first run. No further setup is required.
|
||||
|
||||
### 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">
|
||||
|
||||
The following command adds the server with the Claude Code CLI:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http emdash-docs https://docs.emdashcms.com/mcp
|
||||
```
|
||||
|
||||
Alternatively, commit a `.mcp.json` file at the project root with the following contents:
|
||||
|
||||
```json title=".mcp.json"
|
||||
{
|
||||
"mcpServers": {
|
||||
"emdash-docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="OpenCode">
|
||||
|
||||
Add the following entry to `opencode.jsonc`:
|
||||
|
||||
```jsonc title="opencode.jsonc"
|
||||
{
|
||||
"mcp": {
|
||||
"emdash-docs": {
|
||||
"type": "remote",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Cursor">
|
||||
|
||||
Commit a `.cursor/mcp.json` file at the project root with the following contents, or add the server via **Cursor Settings -> MCP -> Add new MCP server**:
|
||||
|
||||
```json title=".cursor/mcp.json"
|
||||
{
|
||||
"mcpServers": {
|
||||
"emdash-docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="VS Code">
|
||||
|
||||
Add the following entry to `.vscode/mcp.json` in the project, or to user settings:
|
||||
|
||||
```json title=".vscode/mcp.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 the following entry to `claude_desktop_config.json`:
|
||||
|
||||
```json title="claude_desktop_config.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>
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
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. The post appears immediately.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
EmDash uses Live Content Collections, so content is served at runtime and edits appear immediately.
|
||||
</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
|
||||
|
||||
The following configuration registers EmDash as an Astro integration with a local SQLite database and local file storage:
|
||||
|
||||
```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
|
||||
|
||||
The following file connects EmDash to Astro's content system through a single live collection:
|
||||
|
||||
```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, generate an encryption key for plugin secrets with the following command:
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
```
|
||||
|
||||
Add the generated value to your environment as `EMDASH_ENCRYPTION_KEY`, as shown below. This key encrypts plugin secrets at rest. Set it now so your deployment is ready when plugin secret encryption uses it.
|
||||
|
||||
```bash
|
||||
EMDASH_ENCRYPTION_KEY=emdash_enc_v1_...
|
||||
```
|
||||
|
||||
The key is never stored in the database — only encrypted ciphertext is. Back it up somewhere durable, because losing it means losing every secret encrypted with it.
|
||||
|
||||
The preview HMAC secret and commenter-IP hash salt are generated and stored in the database on first use, so you do not need to set them. Override the preview secret only if a separate process needs to verify the same tokens as your main site, as shown below.
|
||||
|
||||
```bash
|
||||
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>
|
||||
```
|
||||
|
||||
The following page fetches a single entry by slug:
|
||||
|
||||
```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
|
||||
|
||||
With the site running, the following pages cover what to do 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
|
||||
@@ -0,0 +1,180 @@
|
||||
---
|
||||
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>
|
||||
|
||||
## The MCP Server
|
||||
|
||||
The MCP server is enabled by default -- the route at `/_emdash/api/mcp` is mounted unless you explicitly turn it off. It always requires authentication, so no content is exposed without valid credentials.
|
||||
|
||||
To disable it, set `mcp: false` in your Astro configuration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
mcp: false,
|
||||
})
|
||||
```
|
||||
|
||||
## 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).
|
||||
@@ -0,0 +1,161 @@
|
||||
---
|
||||
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
|
||||
|
||||
Install the provider package:
|
||||
|
||||
```bash
|
||||
pnpm add @emdash-cms/auth-atproto
|
||||
```
|
||||
|
||||
Add the provider to the EmDash integration:
|
||||
|
||||
```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.
|
||||
|
||||
The provider is a public OAuth client and serves its own metadata document at `/.well-known/atproto-client-metadata.json`, so it works with the configuration above alone — no environment variables, client secret, or OAuth-app registration to set up.
|
||||
|
||||
## Configuration
|
||||
|
||||
The `atproto()` provider accepts an allowlist and a default role:
|
||||
|
||||
```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: enter your handle, authenticate at your provider, and return to EmDash 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
|
||||
|
||||
The same configuration works in 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 login 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.
|
||||
@@ -0,0 +1,431 @@
|
||||
---
|
||||
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. The following example enables GitHub, Google, and Atmosphere:
|
||||
|
||||
```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
|
||||
|
||||
The following example enables the GitHub provider:
|
||||
|
||||
```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
|
||||
|
||||
The following example enables the Google provider:
|
||||
|
||||
```js
|
||||
import { google } from "emdash/auth/providers/google";
|
||||
|
||||
emdash({ authProviders: [google()] });
|
||||
```
|
||||
|
||||
Set credentials via environment variables. EmDash checks the prefixed names first and falls back to the unprefixed ones:
|
||||
|
||||
| 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 [Atmosphere account](/guides/atmosphere-auth/) — the user-owned identity behind Bluesky and the wider AT Protocol network — install the Atmosphere provider:
|
||||
|
||||
```bash
|
||||
pnpm add @emdash-cms/auth-atproto
|
||||
```
|
||||
|
||||
The following example enables the Atmosphere provider with a handle allowlist:
|
||||
|
||||
```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>
|
||||
|
||||
## Letting a group sign in without invites
|
||||
|
||||
To let a group sign in without inviting each user, configure a [login provider](#login-providers) with an allowlist. The Atmosphere provider accepts `allowedHandles` and `allowedDIDs` (see [Atmosphere login](/guides/atmosphere-auth/)); the [Cloudflare Access](#cloudflare-access) adapter provisions users from your identity provider via `autoProvision` and `roleMapping`. Any configured provider can also create the initial admin account.
|
||||
|
||||
## Sessions
|
||||
|
||||
Sessions use secure, HttpOnly, SameSite=Lax cookies and last 30 days with sliding expiration — the expiry resets 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
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
title: Backups
|
||||
description: Download site backups, schedule automatic backups to storage, and restore with D1 Time Travel.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash gives you three layers of protection for your content, from zero-config point-in-time recovery on Cloudflare to downloadable archives you keep yourself.
|
||||
|
||||
## What's in a backup
|
||||
|
||||
A backup contains everything needed to reconstruct your site's content:
|
||||
|
||||
- All content entries, including drafts, scheduled posts, and trashed items
|
||||
- Collection and field definitions (your content model)
|
||||
- Taxonomies and term assignments
|
||||
- Menus, widgets, sections, and SEO settings
|
||||
- Revisions and media metadata
|
||||
- Site settings (title, tagline, display preferences)
|
||||
|
||||
Backups deliberately **exclude**:
|
||||
|
||||
- User accounts, sessions, passkeys, and API tokens — auth data is neither portable nor safe in a downloadable file
|
||||
- Secrets (preview signing secret, plugin configuration)
|
||||
- Media binaries — the actual files live in your storage bucket (R2, S3, or local); a backup carries their metadata so references stay intact
|
||||
|
||||
Backups are JSON files in the same snapshot format used by EmDash's preview system, versioned with the EmDash release that created them.
|
||||
|
||||
## One-click download
|
||||
|
||||
Under **Settings → Backups** in the admin, the **Download backup** button generates a fresh backup and downloads it as a JSON file. Requires the admin role.
|
||||
|
||||
This is the right tool before risky operations: bulk imports, schema changes, or major upgrades.
|
||||
|
||||
## Automatic backups to storage
|
||||
|
||||
If your site has a storage backend configured (R2 on Cloudflare, S3, or local storage), you can enable daily automatic backups:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Open **Settings → Backups** in the admin.
|
||||
|
||||
2. Toggle **Daily automatic backups** on.
|
||||
|
||||
3. Choose how many backups to keep (1–30). Older archives are pruned automatically.
|
||||
|
||||
4. Save. Backups run as part of EmDash's scheduled maintenance — no extra cron setup needed.
|
||||
|
||||
</Steps>
|
||||
|
||||
Archives are stored under the `backups/` prefix in your bucket as `emdash-backup-<timestamp>-<random>.json`. The **Stored Backups** list in the admin lets you download or delete individual archives, and **Back up now** creates one on demand.
|
||||
|
||||
<Aside type="caution">
|
||||
Archives share the bucket with your media files. EmDash's media route refuses to serve anything
|
||||
under `backups/`, and archive names carry a random suffix — but if you expose the bucket itself
|
||||
through a public R2 domain or CDN, anything in it is reachable by URL. Prefer serving media
|
||||
through EmDash (or scope your public domain to the media prefix).
|
||||
</Aside>
|
||||
|
||||
Automatic backups piggyback on the scheduled maintenance tick (the same mechanism that powers
|
||||
scheduled publishing) — on Cloudflare this is the Worker's cron trigger, on Node the built-in
|
||||
scheduler. If your deployment has no cron trigger configured, use **Back up now** or the download
|
||||
button instead.
|
||||
|
||||
## Point-in-time restore with D1 Time Travel
|
||||
|
||||
If your site runs on Cloudflare D1, you already have full database point-in-time recovery — always on, no configuration:
|
||||
|
||||
```bash
|
||||
# See the current bookmark (do this before risky operations)
|
||||
npx wrangler d1 time-travel info my-database
|
||||
|
||||
# Restore the database to a specific timestamp
|
||||
npx wrangler d1 time-travel restore my-database --timestamp=2026-07-08T13:00:00Z
|
||||
```
|
||||
|
||||
Time Travel keeps 30 days of history on the paid plan (7 days on free) with minute-level granularity. It restores the **entire database** — content, users, settings — which makes it the right tool for disaster recovery ("the import went wrong, take me back to an hour ago").
|
||||
|
||||
See the [D1 Time Travel documentation](https://developers.cloudflare.com/d1/reference/time-travel/) for details.
|
||||
|
||||
<Aside type="tip">
|
||||
Time Travel and EmDash backups complement each other: Time Travel is your disaster-recovery net
|
||||
for the last 30 days; downloaded backups are user-holdable archives with no expiry that survive
|
||||
account or database deletion.
|
||||
</Aside>
|
||||
|
||||
## Offsite database dumps
|
||||
|
||||
For a complete SQL dump of the raw database (including users and auth tables), use Wrangler:
|
||||
|
||||
```bash
|
||||
npx wrangler d1 export my-database --remote --output=backup.sql
|
||||
```
|
||||
|
||||
On Node deployments, the database is a single SQLite file — copy it while the server is stopped, or use `sqlite3 emdash.db ".backup backup.db"` for a consistent online copy.
|
||||
|
||||
## Restoring a backup
|
||||
|
||||
Restoring from a backup JSON is intentionally not exposed as a one-click admin action yet — overwriting a live database deserves more friction than a button. Current options:
|
||||
|
||||
- **Cloudflare:** use D1 Time Travel (above) for point-in-time restore.
|
||||
- **Full dumps:** import a `wrangler d1 export` dump with `npx wrangler d1 execute my-database --remote --file=backup.sql`.
|
||||
- **Backup JSON:** the format matches EmDash's snapshot format; a guided CLI restore is planned. Track [Discussion #142](https://github.com/emdash-cms/emdash/discussions/142).
|
||||
@@ -0,0 +1,322 @@
|
||||
---
|
||||
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 covers creating a blog with EmDash, from defining the 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. Customize it from the admin dashboard or the 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**
|
||||
|
||||
The post is now live and appears immediately.
|
||||
|
||||
## Display Posts on Your Site
|
||||
|
||||
### List All Posts
|
||||
|
||||
The following page 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
|
||||
|
||||
The following dynamic route renders an individual post:
|
||||
|
||||
```astro title="src/pages/blog/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
import { PortableText, Image } 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 && (
|
||||
<Image image={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
|
||||
|
||||
The following route lists posts in a single 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
|
||||
|
||||
The following component shows the categories and tags assigned to a post:
|
||||
|
||||
```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, the following route paginates the post list:
|
||||
|
||||
```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
|
||||
|
||||
The following endpoint generates an RSS feed for the 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}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The feed depends on the `@astrojs/rss` package. The following command installs it:
|
||||
|
||||
```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
|
||||
@@ -0,0 +1,434 @@
|
||||
---
|
||||
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 same configuration for its locale list, default locale, and fallback chain.
|
||||
|
||||
```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. A posts table with three translations looks like this:
|
||||
|
||||
```
|
||||
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
|
||||
- **Single-locale queries** — 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.
|
||||
|
||||
<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>
|
||||
|
||||
### Menus
|
||||
|
||||
Menus are per-locale — the same `name` (e.g. `"primary"`) can exist in several
|
||||
locales, all linked via a shared `translation_group`. Menu items resolve their
|
||||
content references against the active locale's version of the referenced
|
||||
content.
|
||||
|
||||
The following component fetches the primary menu for the active locale:
|
||||
|
||||
```astro title="src/components/PrimaryNav.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
const menu = await getMenu("primary", { locale: Astro.currentLocale });
|
||||
---
|
||||
|
||||
<nav aria-label="Primary">
|
||||
<ul>
|
||||
{menu?.items.map((item) => (
|
||||
<li><a href={item.url}>{item.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
|
||||
Create translations of an existing menu from the admin's **Menus** list — the
|
||||
items are cloned with `reference_id` intact (it stores the referenced content's
|
||||
`translation_group`), so the new menu's links point at the right per-locale
|
||||
content automatically.
|
||||
|
||||
### Taxonomies (categories, tags)
|
||||
|
||||
Terms are per-locale. Definitions (`_emdash_taxonomy_defs`) are also per-locale,
|
||||
so `label` / `labelSingular` can be translated too. The pivot
|
||||
`content_taxonomies.taxonomy_id` stores the term's `translation_group`, so a
|
||||
single assignment spans every locale of the content.
|
||||
|
||||
The following example fetches categories and a post's terms for the active locale:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getTaxonomyTerms, getEntryTerms } from "emdash";
|
||||
|
||||
const categories = await getTaxonomyTerms("category", {
|
||||
locale: Astro.currentLocale,
|
||||
});
|
||||
const terms = await getEntryTerms("posts", post.id, undefined, {
|
||||
locale: Astro.currentLocale,
|
||||
});
|
||||
---
|
||||
```
|
||||
|
||||
Translating a piece of content automatically inherits the source's term
|
||||
assignments — you only need to translate the *terms themselves* once, and every
|
||||
post that uses them resolves to the right locale at read time.
|
||||
|
||||
### 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.
|
||||
|
||||
## Sitemaps
|
||||
|
||||
The per-collection sitemap at `/sitemap-{collection}.xml` is locale-aware. When i18n is enabled, each translation is emitted as its own `<url>` entry, with the locale prefix resolved through Astro's `getRelativeLocaleUrl`. Your `prefixDefaultLocale` setting and any custom locale `path` mappings are honoured automatically.
|
||||
|
||||
Translation siblings are cross-linked with `xhtml:link` alternates so search engines can serve the correct language to each user:
|
||||
|
||||
```xml title="/sitemap-post.xml"
|
||||
<url>
|
||||
<loc>https://example.com/blog/hello</loc>
|
||||
<lastmod>2026-05-28T16:33:15.461Z</lastmod>
|
||||
<xhtml:link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
|
||||
<xhtml:link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
|
||||
<xhtml:link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
|
||||
</url>
|
||||
```
|
||||
|
||||
Siblings are grouped by `translation_group`, so a row added later (a new locale variant of an existing post) automatically appears as an alternate on every other variant. Sites with a single locale produce a plain sitemap with no `xhtml` namespace.
|
||||
|
||||
## hreflang Alternates in the Page Head
|
||||
|
||||
The same alternates belong in the `<head>` of every content page. If your layout uses [`<EmDashHead>`](/plugins/creating-plugins/hooks/#pagemetadata), this is automatic: when i18n is enabled and the page context includes `content`, it emits one `<link rel="alternate">` per published translation sibling — including a self-referencing link, as Google recommends — plus `x-default`:
|
||||
|
||||
```html
|
||||
<link rel="alternate" hreflang="en" href="https://example.com/blog/hello" />
|
||||
<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/bonjour" />
|
||||
<link rel="alternate" hreflang="x-default" href="https://example.com/blog/hello" />
|
||||
```
|
||||
|
||||
For hand-rolled heads, resolve the alternates with `getHreflangAlternates`:
|
||||
|
||||
```astro title="src/pages/blog/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry, getHreflangAlternates } from "emdash";
|
||||
|
||||
const { entry } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
const alternates = await getHreflangAlternates("posts", entry.data.id, {
|
||||
siteUrl: Astro.url.origin,
|
||||
});
|
||||
---
|
||||
|
||||
<head>
|
||||
{alternates.map((a) => <link rel="alternate" hreflang={a.hreflang} href={a.href} />)}
|
||||
</head>
|
||||
```
|
||||
|
||||
Behaviour matches the sitemap exactly:
|
||||
|
||||
- **`x-default`** points at the default-locale variant. When the default locale has no published translation, it falls back to the first routable variant, so the set never lacks an `x-default`.
|
||||
- **Unpublished siblings are excluded** — draft translations never leak into alternates.
|
||||
- **Unroutable locales are dropped.** A row whose locale isn't in your configured `i18n.locales` can't be served, and linking search engines to a 404 is worse than no link.
|
||||
- **Untranslated entries** still get a self-referencing alternate and `x-default` when i18n is enabled, mirroring the sitemap.
|
||||
- With **i18n disabled**, the result is empty and no queries run.
|
||||
|
||||
URLs are built from the collection's `urlPattern` and localized through your Astro i18n routing config (`prefixDefaultLocale`, custom locale `path` mappings), so head and sitemap always agree.
|
||||
|
||||
## Importing Multilingual Content
|
||||
|
||||
Import WordPress content through the admin migration tool — see [Content Import](/migration/content-import/) and [Migrate from WordPress](/migration/from-wordpress/). A WXR export does not carry the locale and translation-group structure that WPML or Polylang add, so imported content lands in your default locale.
|
||||
|
||||
To build translations from imported content, create the translated entry and link it to the original:
|
||||
|
||||
```bash
|
||||
emdash content create posts --locale fr --translation-of 01ABC...
|
||||
```
|
||||
|
||||
This is the same `--locale` / `--translation-of` workflow shown in [Seeding multilingual content](#seeding-multilingual-content) above, applied after the import completes.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,477 @@
|
||||
---
|
||||
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 from "emdash/astro";
|
||||
import { r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
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 EmDash media fields, use the `Image` component from `emdash/ui`:
|
||||
|
||||
```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}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
`priority` is for the primary above-the-fold image. It sets `loading="eager"` and `fetchpriority="high"`: `loading` controls whether loading is deferred, and `fetchpriority` gives the browser a priority hint for the request.
|
||||
|
||||
## 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>
|
||||
```
|
||||
|
||||
A successful upload returns the stored media item:
|
||||
|
||||
```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
|
||||
|
||||
The following request lists media under a prefix:
|
||||
|
||||
```bash
|
||||
GET /_emdash/api/media?prefix=images/&limit=20
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
### Delete Media
|
||||
|
||||
The following request deletes a stored file:
|
||||
|
||||
```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 plain-string values)
|
||||
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
|
||||
@@ -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, locale }, ...]
|
||||
```
|
||||
|
||||
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; locale: string }>>`
|
||||
@@ -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';
|
||||
|
||||
Let editors pick a layout per page (e.g. Default, Full Width, Landing Page) from a dropdown in the editor, using a `select` field mapped to layout components in your page route.
|
||||
|
||||
## 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
|
||||
|
||||
This uses EmDash's 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 layout choices:
|
||||
|
||||
- **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.
|
||||
@@ -0,0 +1,378 @@
|
||||
---
|
||||
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**. The middleware verifies the token and the query functions read it through `AsyncLocalStorage`, so the same template code serves draft content during a preview and published content otherwise.
|
||||
|
||||
## Setting Up Preview
|
||||
|
||||
Preview works as soon as EmDash is installed. On first use, EmDash
|
||||
generates a per-site preview secret and stores it in the database, so
|
||||
the common case needs no configuration.
|
||||
|
||||
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.
|
||||
|
||||
Existing templates work with preview automatically, as in the following page:
|
||||
|
||||
```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.
|
||||
|
||||
Pass `baseUrl` to generate an absolute URL:
|
||||
|
||||
```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...
|
||||
```
|
||||
|
||||
Pass `pathPattern` to generate a URL with a custom path:
|
||||
|
||||
```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.
|
||||
|
||||
The following example builds a locale-prefixed preview URL:
|
||||
|
||||
```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 security
|
||||
|
||||
Preview tokens are signed and time-limited. The CLI and the helper functions generate and verify them for you; you do not construct or parse them by hand. A token identifies one entry and stops working after it expires.
|
||||
|
||||
<Aside type="caution">
|
||||
Keep your `EMDASH_PREVIEW_SECRET` secret. Anyone who has it can generate a
|
||||
valid preview token for any content.
|
||||
</Aside>
|
||||
|
||||
## Complete Example
|
||||
|
||||
The following page combines preview and visual editing support in a full blog post template:
|
||||
|
||||
```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>`
|
||||
@@ -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.
|
||||
|
||||
<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. 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
|
||||
@@ -0,0 +1,274 @@
|
||||
---
|
||||
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
|
||||
|
||||
The following example fetches 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
|
||||
|
||||
The following example fetches 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
|
||||
|
||||
A section has the following shape:
|
||||
|
||||
```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. The inserted content is self-contained: editors can customize it, and later changes to the section do not affect copies already placed in content.
|
||||
|
||||
<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
|
||||
|
||||
The following seed file defines two sections for a theme:
|
||||
|
||||
```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"],
|
||||
"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
|
||||
|
||||
The following example renders a section's content server-side, 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, description, and keywords
|
||||
- `options.limit` — Page size (default 50, max 100)
|
||||
- `options.cursor` — Pagination cursor from a previous `nextCursor`
|
||||
|
||||
**Returns:** `Promise<{ items: Section[]; nextCursor?: string }>`
|
||||
|
||||
## REST API
|
||||
|
||||
### List Sections
|
||||
|
||||
List sections, optionally filtered by source or search term:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/sections
|
||||
GET /_emdash/api/sections?source=theme
|
||||
GET /_emdash/api/sections?search=newsletter
|
||||
```
|
||||
|
||||
### Get Section
|
||||
|
||||
Fetch a single section by slug:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/sections/newsletter-cta
|
||||
```
|
||||
|
||||
### Create Section
|
||||
|
||||
Create a section with a POST request:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/sections
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slug": "my-section",
|
||||
"title": "My Section",
|
||||
"description": "Optional description",
|
||||
"keywords": ["keyword1", "keyword2"],
|
||||
"content": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Section
|
||||
|
||||
Update a section with a PUT request:
|
||||
|
||||
```http
|
||||
PUT /_emdash/api/sections/my-section
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Updated Title",
|
||||
"content": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Section
|
||||
|
||||
Delete a section by slug:
|
||||
|
||||
```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
|
||||
@@ -0,0 +1,332 @@
|
||||
---
|
||||
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
|
||||
|
||||
The following component renders a header with the site logo and primary menu:
|
||||
|
||||
```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
|
||||
|
||||
The following component renders links for each configured social platform:
|
||||
|
||||
```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
|
||||
|
||||
The following component builds document and Open Graph meta tags from site settings:
|
||||
|
||||
```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 with a GET request:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/settings
|
||||
```
|
||||
|
||||
The response is a JSON object of the settings:
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,466 @@
|
||||
---
|
||||
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">
|
||||
|
||||
1. Open a content entry in the editor
|
||||
|
||||
2. Find the taxonomy panel in the sidebar
|
||||
|
||||
3. For categories, check the boxes for applicable terms, or click **+ Add New**
|
||||
|
||||
4. For tags, type tag names separated by commas
|
||||
|
||||
5. Save the content
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="API">
|
||||
|
||||
The following request creates a term in the `category` taxonomy:
|
||||
|
||||
```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
|
||||
|
||||
The following example fetches one term by taxonomy and slug:
|
||||
|
||||
```ts
|
||||
import { getTerm } from "emdash";
|
||||
|
||||
const category = await getTerm("category", "news");
|
||||
// Returns TaxonomyTerm or null
|
||||
```
|
||||
|
||||
### Get Terms for an Entry
|
||||
|
||||
The following example retrieves the categories and tags assigned to a single 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
|
||||
|
||||
The following request assigns category terms to a post:
|
||||
|
||||
```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
|
||||
@@ -0,0 +1,368 @@
|
||||
---
|
||||
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
|
||||
|
||||
The following component renders the most recent posts, with optional thumbnails and dates:
|
||||
|
||||
```astro title="src/components/widgets/RecentPosts.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
|
||||
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 && (
|
||||
<Image image={post.data.featured_image} alt="" class="thumbnail" />
|
||||
)}
|
||||
<a href={`/posts/${post.data.slug}`}>{post.data.title}</a>
|
||||
{showDate && post.data.publishedAt && (
|
||||
<time datetime={post.data.publishedAt.toISOString()}>
|
||||
{post.data.publishedAt.toLocaleDateString()}
|
||||
</time>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Search Widget
|
||||
|
||||
The following component renders a search form that submits to a search page:
|
||||
|
||||
```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[]`
|
||||
@@ -0,0 +1,341 @@
|
||||
---
|
||||
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**
|
||||
|
||||
Drafts are only visible in the admin. Change status to **Published** to make content visible on
|
||||
your site.
|
||||
|
||||
## 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
|
||||
- **HTML blocks** - Raw HTML for custom embeds and widgets
|
||||
- **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 |
|
||||
| `/html` | Insert a raw HTML 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**
|
||||
|
||||
### HTML Blocks
|
||||
|
||||
Use `/html` to insert a raw HTML block. This is useful for embedding third-party widgets, custom markup, or content that doesn't fit the standard block types. HTML blocks are also created automatically when importing content from WordPress or Contentful that contains markup EmDash can't convert to native Portable Text blocks.
|
||||
|
||||
<Aside type="caution">
|
||||
HTML blocks are sanitized before rendering on the frontend to prevent XSS attacks. By default, iframes are only allowed from `www.youtube.com` and `player.vimeo.com`. Iframes from other providers will be stripped during sanitization.
|
||||
</Aside>
|
||||
|
||||
To allow iframes from additional providers, override the `htmlBlock` component in your Portable Text rendering:
|
||||
|
||||
```astro
|
||||
---
|
||||
// src/components/MyHtmlBlock.astro
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
const { node } = Astro.props;
|
||||
|
||||
if (!node?.html) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeHtml(node.html, {
|
||||
allowedTags: [...sanitizeHtml.defaults.allowedTags, "img", "span", "iframe"],
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
"*": ["class", "id", "data-*", "style"],
|
||||
iframe: ["src", "width", "height", "frameborder", "allow", "allowfullscreen"],
|
||||
img: ["src", "srcset", "alt", "title", "width", "height", "loading"],
|
||||
},
|
||||
allowedIframeHostnames: [
|
||||
"www.youtube.com",
|
||||
"player.vimeo.com",
|
||||
"iframe.videodelivery.net", // Cloudflare Stream
|
||||
// Add your providers here
|
||||
],
|
||||
});
|
||||
---
|
||||
|
||||
<div class="html-block" set:html={sanitized} />
|
||||
```
|
||||
|
||||
Then pass it to `<PortableText>`:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
import MyHtmlBlock from "../components/MyHtmlBlock.astro";
|
||||
---
|
||||
|
||||
<PortableText
|
||||
value={post.data.content}
|
||||
components={{ type: { htmlBlock: MyHtmlBlock } }}
|
||||
/>
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
### 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
|
||||
|
||||
Restoring a revision creates a new revision with the restored content. The original revision
|
||||
history is preserved.
|
||||
|
||||
## 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">
|
||||
On Node.js deployments, EmDash runs the scheduled publishing sweep automatically. On Cloudflare
|
||||
Workers, it needs a cron trigger to publish content on schedule. This is included by default in new templates. See
|
||||
[Deploy to Cloudflare](/deployment/cloudflare/#scheduled-publishing) for more information.
|
||||
</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
|
||||
|
||||
The following request creates a draft post:
|
||||
|
||||
```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
|
||||
|
||||
The following request updates an existing post and publishes it:
|
||||
|
||||
```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
|
||||
|
||||
The following request permanently deletes a post:
|
||||
|
||||
```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.
|
||||
|
||||
Each translation has its own slug, status, and revision history. Publish, schedule, and manage translations independently.
|
||||
|
||||
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
|
||||
@@ -0,0 +1,257 @@
|
||||
---
|
||||
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 runs as a standalone Astro integration, and pairs with EmDash's CMS fields for per-page pricing when you use EmDash.
|
||||
|
||||
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
|
||||
|
||||
Install the package with your package manager:
|
||||
|
||||
<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, add a regular `number` field to your collection for per-page pricing and read it at request time:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Set the network to a Solana identifier and disable EVM if you only use Solana:
|
||||
|
||||
```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.
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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. Content updates appear instantly at runtime.
|
||||
</Card>
|
||||
<Card title="Type-Safe" icon="setting">
|
||||
Generate TypeScript types from your content model for full type safety and
|
||||
autocomplete, from query 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
|
||||
|
||||
The following commands create a new EmDash site and start the development server. The admin UI is then available at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template @emdash-cms/template-blog
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
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, so edits appear immediately. Content is stored in a SQL database — SQLite, libSQL, Cloudflare D1, or PostgreSQL — and media in S3-compatible storage (R2 or the local filesystem).
|
||||
|
||||
**Key characteristics:**
|
||||
|
||||
- **Visual content modelling** — Define and change collections and fields from the admin UI; changes take effect immediately.
|
||||
- **Live Collections** — Content is served at runtime, so edits appear immediately.
|
||||
- **Plugin system** — WordPress-inspired hooks, storage, settings, and admin UI extensions.
|
||||
- **Cloud-portable** — Runs on Cloudflare (Workers + D1 + R2) or Node.js, with SQLite, libSQL, or PostgreSQL and any S3-compatible storage.
|
||||
|
||||
## What EmDash Is Not
|
||||
|
||||
- **Not a headless CMS** — EmDash is tightly integrated with Astro and runs in the same deployment, rather than as a separate service you call over an API.
|
||||
- **Not WordPress-compatible** — It does not run PHP or WordPress plugins. WordPress content and concepts migrate to EmDash equivalents.
|
||||
- **Not a page builder** — EmDash manages structured content. Build visual layouts with Astro components.
|
||||
|
||||
## Who EmDash Is For
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Agency developers" icon="laptop">
|
||||
Spin up client sites quickly with reusable plugins and themes. Plugins run in isolated contexts
|
||||
with explicit APIs.
|
||||
</Card>
|
||||
<Card title="Solo developers" icon="seti:todo">
|
||||
Content management is part of the Astro site, deployed and managed as one project.
|
||||
</Card>
|
||||
<Card title="Content editors" icon="pencil">
|
||||
Create and edit content in the admin panel.
|
||||
</Card>
|
||||
<Card title="WordPress users" icon="right-arrow">
|
||||
Migration path for content and plugins. Modern tooling, familiar concepts.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
The following diagram shows how EmDash sits inside an Astro site, connecting the content engine, admin panel, and plugins to the data layer.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Your Astro Site │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ EmDash Integration │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
|
||||
│ │ │ Engine │ │ Panel │ │ │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────────────────┐│ │
|
||||
│ │ │ Data Layer ││ │
|
||||
│ │ │Database (SQLite/libSQL/D1/Postgres) + media store ││ │
|
||||
│ │ └───────────────────────────────────────────────────┘│ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Astro Framework │ │
|
||||
│ │ Live Collections • Sessions • Middleware │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
EmDash is Astro-native and cloud-portable. It runs on Cloudflare (D1 + R2 + Workers) or Node.js,
|
||||
with SQLite, libSQL, or PostgreSQL and any S3-compatible storage.
|
||||
</Aside>
|
||||
|
||||
## Core Concepts
|
||||
|
||||
These are the key concepts used throughout the documentation:
|
||||
|
||||
- **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>
|
||||
@@ -0,0 +1,327 @@
|
||||
---
|
||||
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 imports content from WordPress and other platforms. Each import source detects a platform, analyzes its content, and fetches it into your site.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The import system exposes these endpoints:
|
||||
|
||||
### Probe URL
|
||||
|
||||
Detect the platform behind a URL with a probe request:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/probe
|
||||
Content-Type: application/json
|
||||
|
||||
{ "url": "https://example.com" }
|
||||
```
|
||||
|
||||
The response contains the detected platform and suggested action.
|
||||
|
||||
### Analyze WXR
|
||||
|
||||
Upload a WXR file to analyze its post types and schema compatibility:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/analyze
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: [WordPress export .xml]
|
||||
```
|
||||
|
||||
The response contains the post type analysis with schema compatibility.
|
||||
|
||||
### Prepare Schema
|
||||
|
||||
Create the collections and fields for the selected post types:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/prepare
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"postTypes": [
|
||||
{ "name": "post", "collection": "posts", "enabled": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Execute Import
|
||||
|
||||
Import the content into the mapped collections:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/execute
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: [WordPress export .xml]
|
||||
config: { "postTypeMappings": { "post": { "collection": "posts" } } }
|
||||
```
|
||||
|
||||
### Import Media
|
||||
|
||||
Download and store the media attachments referenced by the import:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/media
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"attachments": [{ "id": 123, "url": "https://..." }],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
The response streams NDJSON progress updates during download and upload.
|
||||
|
||||
### Rewrite URLs
|
||||
|
||||
Replace old media URLs in imported content with their stored equivalents:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/rewrite-urls
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"urlMap": { "https://old.com/image.jpg": "/_emdash/media/abc123" }
|
||||
}
|
||||
```
|
||||
|
||||
## 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 an import completes, EmDash shows a summary of what succeeded, what was adjusted, and what failed:
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
## Custom sources
|
||||
|
||||
To import from a platform the built-in sources do not cover, implement the `ImportSource` interface and register it on the integration:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { mySource } from "./src/import/custom-source";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
import: { sources: [mySource] },
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The interface (`probe`, `analyze`, `fetchContent`) and the normalized output shape are documented in [Architecture (internals)](/contributing/architecture/#extending-the-content-importer).
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[WordPress Migration](/migration/from-wordpress/)** — Complete WordPress migration guide
|
||||
- **[Plugin Porting](/migration/porting-plugins/)** — Port WordPress plugins to EmDash
|
||||
@@ -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 migrates content from WordPress. Import your posts, pages, media, and taxonomies through the admin dashboard.
|
||||
|
||||
## 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/porting-plugins/)** — Migrate WordPress plugin functionality
|
||||
- **[Working with Content](/guides/working-with-content/)** — Query and render your imported content
|
||||
@@ -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. Provide the WordPress plugin code along with
|
||||
the EmDash Plugin API documentation to generate a first draft. Complex plugins may need several
|
||||
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](/plugins/creating-plugins/hooks/) — All hooks with signatures
|
||||
- [Storage](/plugins/creating-plugins/storage/) — Collections and queries
|
||||
- [Settings](/plugins/creating-plugins/settings/) — KV-backed settings via Block Kit
|
||||
- [React Admin Pages & Widgets](/plugins/creating-native-plugins/react-admin/) — Building admin pages (native plugins)
|
||||
@@ -0,0 +1,141 @@
|
||||
---
|
||||
title: Distributing native plugins
|
||||
description: Package and ship a native plugin via npm.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
Native plugins distribute via npm. A native plugin is a regular npm package that exports a descriptor factory plus `createPlugin`. Site operators install it with `npm install` and register it in `astro.config.mjs`.
|
||||
|
||||
## Package layout
|
||||
|
||||
A typical native plugin package has the following layout:
|
||||
|
||||
```
|
||||
@my-org/plugin-analytics/
|
||||
├── src/
|
||||
│ ├── index.ts # Descriptor + createPlugin
|
||||
│ ├── admin.tsx # React admin components (optional)
|
||||
│ └── astro/ # Astro components for PT block rendering (optional)
|
||||
│ └── index.ts
|
||||
├── dist/ # build output
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## package.json
|
||||
|
||||
The following `package.json` declares the build scripts, exports, and peer dependencies a distributable plugin needs:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "@my-org/plugin-analytics",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/admin.tsx --format esm --dts --clean",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"emdash": "*",
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Required if you use… | Built for |
|
||||
| ----------- | --------------------------------------------- | --------- |
|
||||
| `"."` | Always | Server |
|
||||
| `"./admin"` | React admin pages or widgets | Browser |
|
||||
| `"./astro"` | Portable Text rendering components | Server (SSR) |
|
||||
|
||||
Skip the `./admin` and `./astro` exports if your plugin doesn't need them.
|
||||
|
||||
<Aside type="tip">
|
||||
Keep `emdash` and `react` as peer dependencies, not regular dependencies. The host site provides the actual versions; bundling your own copy causes version conflicts and duplicate React copies in the admin UI.
|
||||
</Aside>
|
||||
|
||||
## Build configuration
|
||||
|
||||
Native plugins ship as ES modules. Most authors use `tsdown` (or `tsup`) with TypeScript. Externalise `react`, `emdash`, and `@emdash-cms/admin` so they aren't bundled into your output:
|
||||
|
||||
```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"],
|
||||
};
|
||||
```
|
||||
|
||||
If you ship Astro components for Portable Text rendering, those don't need bundling — Astro consumes the `.astro` source files directly. List the `astro/` directory under `files` so they're included in the npm tarball.
|
||||
|
||||
## Versioning
|
||||
|
||||
Use semantic versioning. Bumping a major version is a signal to operators that they may need to make changes when upgrading. The shape of `definePlugin()` and the plugin context API are stable, but if you change your plugin's hook behaviour, capability requirements, or settings schema in a way that affects existing installs, that's a breaking change.
|
||||
|
||||
Capabilities are part of the plugin's surface contract. Adding one in a non-major release means existing operators upgrade and silently grant a new capability — that's fine for a sandboxed plugin where the consent dialog re-prompts, but native plugins don't have a consent flow. Treat capability additions as a major version bump for native plugins, or document them very prominently in release notes.
|
||||
|
||||
## README and documentation
|
||||
|
||||
A good plugin README covers:
|
||||
|
||||
- What the plugin does, in one sentence.
|
||||
- How to install it (`npm install ...` and the `astro.config.mjs` snippet with the import).
|
||||
- What capabilities it declares and what it uses them for.
|
||||
- Any required Astro template changes (e.g. `<EmDashHead />` for `page:metadata`, `<EmDashBodyEnd />` for `page:fragments`).
|
||||
- Settings and what each one controls.
|
||||
- Migration notes between major versions.
|
||||
|
||||
## Publishing to npm
|
||||
|
||||
Bump the version and publish the package:
|
||||
|
||||
```bash
|
||||
npm version patch # or minor/major
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
For scoped packages, `--access public` is required on the first publish (npm defaults scoped packages to private).
|
||||
|
||||
## Local development against a host site
|
||||
|
||||
When iterating on a plugin, link it into a test site rather than republishing on every change:
|
||||
|
||||
```bash
|
||||
# In the plugin package
|
||||
pnpm build --watch
|
||||
|
||||
# In the test site
|
||||
pnpm add file:../plugins/my-plugin
|
||||
# or with workspaces:
|
||||
pnpm add @my-org/plugin-analytics --workspace
|
||||
```
|
||||
|
||||
Then register the plugin in the test site's `astro.config.mjs` and run the dev server. Hook handlers run on the next request after `pnpm build` finishes.
|
||||
|
||||
## Marketplace availability
|
||||
|
||||
Native plugins can't be published to the EmDash marketplace. The marketplace is sandboxed-only: every published plugin runs through `emdash plugin bundle` (which validates that the backend code is self-contained, doesn't import Node.js built-ins, and stays under size limits), gets a security audit, and runs in the sandbox runtime when installed.
|
||||
|
||||
A plugin that uses native-only features can sometimes drop them and become sandboxable — for example, by removing `page:fragments` or converting `settingsSchema` to a Block Kit settings page. See [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) for the trade-offs.
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
title: Page fragments
|
||||
description: Inject scripts, stylesheets, or HTML into rendered public pages — native plugins only.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
The `page:fragments` hook lets a plugin contribute raw HTML, scripts, or stylesheets to public pages. It's the right tool for analytics tags, third-party widgets, custom CSS, and anything else that needs to ship JavaScript or markup directly into the visitor's browser.
|
||||
|
||||
It's restricted to native plugins because its output runs as first-party code in the browser, outside any sandbox boundary. If you only need to contribute structured metadata — meta tags, OpenGraph, JSON-LD, allowlisted `<link>` rels — use `page:metadata` instead, which is available to both sandboxed and native plugins. See [Hooks: page:metadata](/plugins/creating-plugins/hooks/#pagemetadata).
|
||||
|
||||
## Capability
|
||||
|
||||
`page:fragments` requires the `hooks.page-fragments:register` capability:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
return definePlugin({
|
||||
id: "analytics-gtm",
|
||||
version: "1.0.0",
|
||||
capabilities: ["hooks.page-fragments:register"],
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
The capability must also appear on the descriptor.
|
||||
|
||||
## Where fragments render
|
||||
|
||||
Templates opt into receiving fragments by including the relevant components from `emdash/ui`:
|
||||
|
||||
- `<EmDashHead />` — renders fragments with `placement: "head"` plus all `page:metadata` contributions.
|
||||
- `<EmDashBodyStart />` — renders fragments with `placement: "body:start"`.
|
||||
- `<EmDashBodyEnd />` — renders fragments with `placement: "body:end"`.
|
||||
|
||||
Templates that omit one of these components silently ignore fragments targeting that placement — your plugin doesn't break, the fragments just don't appear. Document your placement requirement in the plugin's README.
|
||||
|
||||
## Contribution kinds
|
||||
|
||||
A hook can return one of three contribution kinds:
|
||||
|
||||
```typescript
|
||||
type PageFragmentContribution =
|
||||
| {
|
||||
kind: "external-script";
|
||||
placement: PagePlacement;
|
||||
src: string;
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
attributes?: Record<string, string>;
|
||||
key?: string;
|
||||
}
|
||||
| {
|
||||
kind: "inline-script";
|
||||
placement: PagePlacement;
|
||||
code: string;
|
||||
attributes?: Record<string, string>;
|
||||
key?: string;
|
||||
}
|
||||
| {
|
||||
kind: "html";
|
||||
placement: PagePlacement;
|
||||
html: string;
|
||||
key?: string;
|
||||
};
|
||||
```
|
||||
|
||||
`PagePlacement` is `"head" | "body:start" | "body:end"`.
|
||||
|
||||
## Examples
|
||||
|
||||
### External script
|
||||
|
||||
The following hook injects a third-party tag manager into `<head>`:
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
const containerId = await ctx.kv.get<string>("settings:gtmContainerId");
|
||||
if (!containerId) return null;
|
||||
|
||||
return {
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: `https://www.googletagmanager.com/gtm.js?id=${containerId}`,
|
||||
async: true,
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
### Inline script
|
||||
|
||||
The following hook runs a small piece of JavaScript at the top of `<body>` on content pages:
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
if (event.page.kind !== "content") return null;
|
||||
return {
|
||||
kind: "inline-script",
|
||||
placement: "body:start",
|
||||
code: `window.contentId = ${JSON.stringify(event.page.content?.id)};`,
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Inline script content is inserted verbatim. Treat any value you interpolate as untrusted output and use `JSON.stringify()` (or equivalent) to escape it. Never concatenate raw user input into inline JS.
|
||||
</Aside>
|
||||
|
||||
### HTML fragment
|
||||
|
||||
The following hook appends a noscript fallback at the end of `<body>`:
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
const containerId = await ctx.kv.get<string>("settings:gtmContainerId");
|
||||
if (!containerId) return null;
|
||||
|
||||
return {
|
||||
kind: "html",
|
||||
placement: "body:end",
|
||||
html: `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${containerId}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`,
|
||||
};
|
||||
},
|
||||
```
|
||||
|
||||
### Multiple fragments
|
||||
|
||||
A hook can return an array to contribute multiple fragments at once. The following hook adds both a script and a noscript fallback:
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
const id = await ctx.kv.get<string>("settings:gtmContainerId");
|
||||
if (!id) return null;
|
||||
|
||||
return [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: `https://www.googletagmanager.com/gtm.js?id=${id}`,
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
kind: "html",
|
||||
placement: "body:end",
|
||||
html: `<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=${id}" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>`,
|
||||
},
|
||||
];
|
||||
},
|
||||
```
|
||||
|
||||
## Page event
|
||||
|
||||
The `page:fragments` hook receives the same event shape as `page:metadata`:
|
||||
|
||||
```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 };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use `event.page.kind` and `event.page.pageType` to decide whether to contribute on a given page — for example, skipping analytics on admin previews or only injecting JSON-LD on blog posts.
|
||||
|
||||
## When to use `page:metadata` instead
|
||||
|
||||
If what you actually need is:
|
||||
|
||||
- A meta description, robots directive, or Twitter card → `page:metadata` with `kind: "meta"`.
|
||||
- An OpenGraph property → `page:metadata` with `kind: "property"`.
|
||||
- A canonical or alternate `<link>` → `page:metadata` with `kind: "link"`.
|
||||
- A JSON-LD graph → `page:metadata` with `kind: "jsonld"`.
|
||||
|
||||
`page:metadata` works in sandboxed plugins, gets validation and deduplication for free, and avoids the trust burden of shipping raw HTML to visitors. Reach for `page:fragments` only when you need to ship JavaScript or HTML.
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
title: Portable Text rendering components
|
||||
description: Provide Astro components that render plugin-defined Portable Text block types on the public site.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins can add custom block types to the Portable Text editor — YouTube embeds, code snippets, image galleries, anything that isn't covered by the default block set. Sandboxed plugins can declare the editing UI for these blocks (using Block Kit fields), but the Astro components that **render** them on the public site have to be loaded at build time from npm. That's the part that requires a native plugin.
|
||||
|
||||
If your plugin only needs editing-side fields and someone else is providing the rendering components (or the site is providing them locally), you can stay sandboxed. If the plugin should ship the rendering components too, you need to be native.
|
||||
|
||||
## Declaring block types
|
||||
|
||||
Both sandboxed and native plugins can declare block types. Native plugins do it inside `definePlugin()` under `admin.portableTextBlocks`:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
admin: {
|
||||
portableTextBlocks: [
|
||||
{
|
||||
type: "youtube",
|
||||
label: "YouTube Video",
|
||||
icon: "video", // 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 editor's slash command menu.
|
||||
- **`icon`** — `video`, `code`, `link`, or `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.
|
||||
|
||||
## Rendering on the public site
|
||||
|
||||
To render block types on the public site, export Astro components from a `componentsEntry`. The export name is **required** to be `blockComponents`:
|
||||
|
||||
```typescript title="src/astro/index.ts"
|
||||
import YouTube from "./YouTube.astro";
|
||||
import CodePen from "./CodePen.astro";
|
||||
|
||||
export const blockComponents = {
|
||||
youtube: YouTube,
|
||||
codepen: CodePen,
|
||||
};
|
||||
```
|
||||
|
||||
Set `componentsEntry` on the descriptor:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
export function myPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "embeds",
|
||||
version: "1.0.0",
|
||||
format: "native",
|
||||
entrypoint: "@my-org/embeds",
|
||||
componentsEntry: "@my-org/embeds/astro",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
EmDash merges plugin block components into `<PortableText>` automatically — site authors don't need to import anything. User-provided components (declared in the site's `components` prop on `<PortableText>`) take precedence over plugin defaults.
|
||||
|
||||
## 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" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `./astro` export is server-side (Astro SSR), the `./admin` export is browser-side (React), and the `"."` export is the descriptor + `createPlugin`. Keep them in separate files because they bundle for different environments.
|
||||
|
||||
<Aside type="tip">
|
||||
The bundled `@emdash-cms/plugin-embeds` plugin is a complete example of this pattern — YouTube, Vimeo, Tweet, Bluesky, Mastodon, Gist, and Link Preview block types with both admin editing fields and site-side Astro rendering components.
|
||||
</Aside>
|
||||
|
||||
## Sandbox-friendly variants
|
||||
|
||||
If you want a plugin to be sandboxed but still ship a default rendering experience, the usual pattern is:
|
||||
|
||||
1. Ship the sandboxed plugin (editing fields only) on the marketplace.
|
||||
2. Ship a separate companion native package on npm that provides the Astro rendering components.
|
||||
3. Document both: end users install the sandboxed plugin from the marketplace and `npm install` the companion package for rendering.
|
||||
|
||||
This trades a one-step install for keeping the editor side sandboxed. Going fully native is simpler when block rendering is involved, but this split remains an option.
|
||||
@@ -0,0 +1,414 @@
|
||||
---
|
||||
title: React admin pages and widgets
|
||||
description: Ship custom React components for plugin admin pages and dashboard widgets.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Native plugins can extend the admin panel with custom React pages and dashboard widgets — sandboxed plugins describe their UI as [Block Kit](/plugins/creating-plugins/block-kit/) instead, because shipping plugin JavaScript into the admin would break sandbox isolation.
|
||||
|
||||
If your plugin only needs a settings form, the auto-generated `admin.settingsSchema` form (see [Your first native plugin](/plugins/creating-native-plugins/your-first-native-plugin/#settings-ui)) covers most cases without writing any React. Reach for custom components when you need richer UI than `settingsSchema` provides.
|
||||
|
||||
## Admin entry point
|
||||
|
||||
Plugins with admin UI export `pages` and `widgets` objects from an `admin` entrypoint:
|
||||
|
||||
```typescript title="src/admin.tsx"
|
||||
import { SEOSettingsPage } from "./components/SEOSettingsPage";
|
||||
import { SEODashboardWidget } from "./components/SEODashboardWidget";
|
||||
|
||||
export const widgets = {
|
||||
"seo-overview": SEODashboardWidget,
|
||||
};
|
||||
|
||||
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 from `definePlugin()`:
|
||||
|
||||
```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" }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The descriptor needs a matching `adminEntry` so EmDash knows where to find the components at build time:
|
||||
|
||||
```typescript
|
||||
adminEntry: "@my-org/plugin-seo/admin",
|
||||
```
|
||||
|
||||
## Admin pages
|
||||
|
||||
Admin pages are React components that mount under `/_emdash/admin/plugins/<plugin-id>/<path>`.
|
||||
|
||||
### Page definition
|
||||
|
||||
Declare each page under `admin.pages` with a path, label, and icon:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
pages: [
|
||||
{
|
||||
path: "/settings",
|
||||
label: "Settings",
|
||||
icon: "settings",
|
||||
},
|
||||
{
|
||||
path: "/reports",
|
||||
label: "Reports",
|
||||
icon: "chart",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Page component
|
||||
|
||||
The following component reads and saves settings through the plugin API hook:
|
||||
|
||||
```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 as string) || ""}
|
||||
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin API hook
|
||||
|
||||
`usePluginAPI()` calls your plugin's routes with the plugin id prefix and the `X-EmDash-Request: 1` CSRF header added automatically:
|
||||
|
||||
```typescript
|
||||
import { usePluginAPI } from "@emdash-cms/admin";
|
||||
|
||||
function MyComponent() {
|
||||
const api = usePluginAPI();
|
||||
|
||||
const data = await api.get("status"); // GET /_emdash/api/plugins/<id>/status
|
||||
await api.post("settings/save", { enabled: true }); // POST with JSON body
|
||||
const result = await api.get("history?limit=50"); // query params supported
|
||||
}
|
||||
```
|
||||
|
||||
## Dashboard widgets
|
||||
|
||||
Widgets appear on the admin dashboard and provide at-a-glance information.
|
||||
|
||||
### Widget definition
|
||||
|
||||
Declare each widget under `admin.widgets` with an id, title, and size:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
widgets: [
|
||||
{
|
||||
id: "seo-overview",
|
||||
title: "SEO Overview",
|
||||
size: "half", // "full" | "half" | "third"
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Widget component
|
||||
|
||||
The following component fetches its data on mount and renders a compact summary:
|
||||
|
||||
```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 as { message: string }).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";
|
||||
|
||||
export const pages = {
|
||||
"/settings": SettingsPage,
|
||||
"/reports": ReportsPage,
|
||||
};
|
||||
|
||||
export const widgets = {
|
||||
status: StatusWidget,
|
||||
overview: OverviewWidget,
|
||||
};
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Page paths in `pages` should match the `path` values in `admin.pages`. A trailing slash is treated as equivalent, so `/settings` and `/settings/` resolve to the same page. Opening a plugin at its root resolves to the first registered page. 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. Reach for custom React pages only when you need behaviour beyond a basic form.
|
||||
|
||||
## Navigation
|
||||
|
||||
Plugin pages appear in the admin sidebar under the plugin's name. The order matches the `admin.pages` array, as shown below:
|
||||
|
||||
```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. The following bundler configuration builds both the server and admin entrypoints:
|
||||
|
||||
<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");
|
||||
```
|
||||
|
||||
## Complete example
|
||||
|
||||
The following plugin defines a dashboard page, a settings page, and a widget, with the runtime and admin entrypoints in separate files. The `src/index.ts` file holds the descriptor and runtime:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
export function analyticsPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "analytics",
|
||||
version: "1.0.0",
|
||||
format: "native",
|
||||
entrypoint: "@my-org/plugin-analytics",
|
||||
adminEntry: "@my-org/plugin-analytics/admin",
|
||||
adminPages: [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
|
||||
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||
],
|
||||
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlugin() {
|
||||
return 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 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
The `src/admin.tsx` file maps page paths and widget ids to their React components:
|
||||
|
||||
```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,
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
title: Your first native plugin
|
||||
description: Build a native EmDash plugin with the descriptor + createPlugin pattern.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
This guide walks through building a native plugin from scratch. Native plugins run in the same process as your Astro site with full access to the runtime, including React admin pages, Portable Text components, and page fragments.
|
||||
|
||||
If you haven't decided yet whether you want a native plugin instead of a sandboxed one, read [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) first. Native is the format for plugins that need React admin pages, Portable Text rendering components, or page fragments.
|
||||
|
||||
## Two pieces, in one or two files
|
||||
|
||||
Like sandboxed plugins, native plugins ship two pieces:
|
||||
|
||||
1. **A descriptor factory** — returns a `PluginDescriptor` with `format: "native"` plus admin-related entrypoints. Imported by `astro.config.mjs` at build time.
|
||||
2. **A `createPlugin(options)` function** — the runtime side. Returns a `definePlugin({ id, version, capabilities, hooks, routes, admin })` result.
|
||||
|
||||
Unlike sandboxed plugins, both pieces can live in the same file because they don't run in different environments — the whole plugin runs in-process. The package's `"."` export points at a file that exports both the descriptor factory and a `createPlugin` (or `default`) function:
|
||||
|
||||
```
|
||||
my-native-plugin/
|
||||
├── src/
|
||||
│ ├── index.ts # Descriptor factory + createPlugin
|
||||
│ ├── admin.tsx # React admin components (optional)
|
||||
│ └── astro/ # Astro components for PT block rendering (optional)
|
||||
│ └── index.ts
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Set up the package
|
||||
|
||||
The following `package.json` declares the entrypoints and peer dependencies a native plugin needs:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "@my-org/plugin-analytics",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./admin": {
|
||||
"types": "./dist/admin.d.ts",
|
||||
"import": "./dist/admin.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"peerDependencies": {
|
||||
"emdash": "*",
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `emdash` and `react` as peer dependencies so the host site provides the actual versions and you don't ship duplicates.
|
||||
|
||||
## Write the descriptor and runtime
|
||||
|
||||
The following `src/index.ts` defines the descriptor factory and the `createPlugin` runtime in one file:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
export interface AnalyticsOptions {
|
||||
enabled?: boolean;
|
||||
maxEvents?: number;
|
||||
}
|
||||
|
||||
export function analyticsPlugin(options: AnalyticsOptions = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "analytics",
|
||||
version: "0.1.0",
|
||||
format: "native",
|
||||
entrypoint: "@my-org/plugin-analytics",
|
||||
options,
|
||||
adminEntry: "@my-org/plugin-analytics/admin",
|
||||
adminPages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
|
||||
adminWidgets: [{ id: "events-today", title: "Events Today", size: "third" }],
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlugin(options: AnalyticsOptions = {}) {
|
||||
const maxEvents = options.maxEvents ?? 100;
|
||||
|
||||
return definePlugin({
|
||||
id: "analytics",
|
||||
version: "0.1.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: options.enabled ?? true },
|
||||
},
|
||||
pages: [{ path: "/dashboard", label: "Dashboard", icon: "chart" }],
|
||||
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
|
||||
},
|
||||
|
||||
hooks: {
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
ctx.log.info("Analytics plugin installed", { maxEvents });
|
||||
},
|
||||
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const enabled = await ctx.kv.get<boolean>("settings:enabled");
|
||||
if (enabled === false) return;
|
||||
|
||||
await ctx.storage.events.put(`evt_${Date.now()}`, {
|
||||
type: "content:save",
|
||||
contentId: event.content.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
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 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
Key details of this configuration:
|
||||
|
||||
- **`format: "native"` is required.** `"native"` is also the default, but stating it explicitly on every descriptor makes the format easy to spot.
|
||||
- **`entrypoint` is the package's main export.** EmDash imports it at runtime and calls the default export to construct the resolved plugin.
|
||||
- **`options` flow descriptor → `createPlugin`.** Anything the user passes when registering the plugin (`analyticsPlugin({ enabled: false })`) is preserved on the descriptor and forwarded to `createPlugin`. Sandboxed plugins don't have this surface — they read settings from KV instead.
|
||||
- **`id`, `version`, and `capabilities` appear twice.** Once on the descriptor, once on `definePlugin()`. They should match. The descriptor's copy is what `astro.config.mjs` sees at build time; the `definePlugin()` copy is what runs at request time.
|
||||
- **Native route handlers take a single argument** — `(ctx: RouteContext)` where `ctx.input`, `ctx.request`, and `ctx.requestMeta` are merged with the regular `PluginContext` properties. This is the opposite of standard format's two-argument shape. See [API routes](/plugins/creating-plugins/api-routes/) for the full surface (everything else is identical).
|
||||
|
||||
## Plugin id rules
|
||||
|
||||
The `id` field must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The id is used as a single path segment in plugin route URLs and as part of generated SQL identifiers for plugin storage indexes, so anything outside that pattern fails at runtime. The following values show which ids are accepted:
|
||||
|
||||
```typescript
|
||||
// Valid
|
||||
"seo";
|
||||
"audit-log";
|
||||
"audit_log";
|
||||
"plugin-forms";
|
||||
|
||||
// Invalid
|
||||
"@my-org/plugin-forms"; // scoped form not allowed at runtime
|
||||
"MyPlugin"; // no uppercase
|
||||
"42-plugin"; // can't start with a digit
|
||||
"my.plugin"; // no dots
|
||||
```
|
||||
|
||||
Pair an unscoped `id` with a scoped npm package name in `entrypoint` — the package name and the plugin id are separate concerns.
|
||||
|
||||
## Version format
|
||||
|
||||
Use semantic versioning. The following values show which version strings are accepted:
|
||||
|
||||
```typescript
|
||||
version: "1.0.0"; // valid
|
||||
version: "1.2.3-beta"; // valid (prerelease)
|
||||
version: "1.0"; // invalid (missing patch)
|
||||
```
|
||||
|
||||
## Register the plugin
|
||||
|
||||
In your site's `astro.config.mjs`, import the descriptor factory and pass it into the `plugins: []` array — native plugins always run in-process, never in `sandboxed: []`:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { analyticsPlugin } from "@my-org/plugin-analytics";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [
|
||||
analyticsPlugin({ enabled: true, maxEvents: 500 }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Settings UI
|
||||
|
||||
Native plugins can use `admin.settingsSchema` for an auto-generated settings form, which is the simplest path:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
apiKey: { type: "secret", label: "API Key" },
|
||||
enabled: { type: "boolean", label: "Enabled", default: true },
|
||||
maxItems: { type: "number", label: "Max items", min: 1, max: 1000, default: 100 },
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
Field types: `string`, `number`, `boolean`, `select`, `secret`, `url`, `email`. Each accepts `label`, `description`, `default`, plus type-specific extras like `min`/`max`/`options`. Settings are persisted to the same per-plugin KV store sandboxed plugins use — read them with `ctx.kv.get<T>("settings:<key>")` from anywhere.
|
||||
|
||||
For richer settings UI than `settingsSchema` provides, ship custom React pages — see [React admin pages and widgets](/plugins/creating-native-plugins/react-admin/).
|
||||
|
||||
## Complete example — audit log plugin
|
||||
|
||||
The following plugin records every content create, update, and delete to indexed storage and exposes a recent-activity route:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
interface AuditEntry {
|
||||
timestamp: string;
|
||||
action: "create" | "update" | "delete";
|
||||
collection: string;
|
||||
resourceId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function auditLogPlugin(): PluginDescriptor {
|
||||
return {
|
||||
id: "audit-log",
|
||||
version: "0.1.0",
|
||||
format: "native",
|
||||
entrypoint: "@emdash-cms/plugin-audit-log",
|
||||
};
|
||||
}
|
||||
|
||||
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: {
|
||||
"content:afterSave": {
|
||||
priority: 200,
|
||||
handler: async (event, ctx) => {
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: event.isNew ? "create" : "update",
|
||||
collection: event.collection,
|
||||
resourceId: event.content.id as string,
|
||||
};
|
||||
await ctx.storage.entries.put(`${Date.now()}-${event.content.id}`, entry);
|
||||
},
|
||||
},
|
||||
|
||||
"content:afterDelete": {
|
||||
priority: 200,
|
||||
handler: async (event, ctx) => {
|
||||
await ctx.storage.entries.put(`${Date.now()}-${event.id}`, {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "delete",
|
||||
collection: event.collection,
|
||||
resourceId: event.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),
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Test a native plugin by creating a minimal Astro site with the plugin registered:
|
||||
|
||||
<Steps>
|
||||
1. Create a test site with EmDash installed.
|
||||
2. Register your plugin in `astro.config.mjs`, importing it directly from your local source path.
|
||||
3. Run the dev server and trigger hooks by creating, updating, or deleting content.
|
||||
4. Check the console for `ctx.log` output and verify storage via API routes.
|
||||
</Steps>
|
||||
|
||||
For unit tests, mock the `PluginContext` interface and call hook handlers directly.
|
||||
|
||||
## What's next
|
||||
|
||||
- [React admin pages and widgets](/plugins/creating-native-plugins/react-admin/) — ship custom React UI for the admin panel.
|
||||
- [Portable Text rendering components](/plugins/creating-native-plugins/portable-text-components/) — provide Astro components that render plugin-defined block types.
|
||||
- [Page fragments](/plugins/creating-native-plugins/page-fragments/) — inject scripts, stylesheets, or HTML into public pages.
|
||||
- [Distributing native plugins](/plugins/creating-native-plugins/distributing/) — npm packaging and versioning.
|
||||
@@ -0,0 +1,381 @@
|
||||
---
|
||||
title: API Routes
|
||||
description: Expose REST endpoints from your sandboxed plugin for the admin UI and external integrations.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins can expose API routes for their admin UI and external integrations. Routes are mounted under `/_emdash/api/plugins/<slug>/<route-name>` (the `<slug>` is the plugin's `slug` field from `emdash-plugin.jsonc` — exposed at runtime as `ctx.plugin.id`) and run inside the sandbox runtime with the same `PluginContext` that hooks receive.
|
||||
|
||||
This page covers sandboxed plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for details.
|
||||
|
||||
## Defining routes
|
||||
|
||||
Declare routes in the default export of `src/plugin.ts`:
|
||||
|
||||
```typescript title="src/plugin.ts"
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
import { z } from "astro/zod";
|
||||
|
||||
export default {
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (_routeCtx, ctx) => {
|
||||
return { ok: true, plugin: ctx.plugin.id };
|
||||
},
|
||||
},
|
||||
|
||||
submissions: {
|
||||
input: z.object({
|
||||
formId: z.string().optional(),
|
||||
limit: z.number().default(50),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const { formId, limit, cursor } = routeCtx.input;
|
||||
|
||||
const result = await ctx.storage.submissions.query({
|
||||
where: formId ? { formId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
|
||||
return result;
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
`satisfies SandboxedPlugin` infers `routeCtx` and `ctx` — no parameter annotations needed. Sandboxed route handlers take **two arguments**: `(routeCtx, ctx)`.
|
||||
|
||||
- `routeCtx` carries request-shaped data: `{ input, request, requestMeta }`.
|
||||
- `ctx` is the same `PluginContext` you get inside hooks — `ctx.storage`, `ctx.kv`, `ctx.content`, `ctx.http`, `ctx.log`, etc.
|
||||
|
||||
## Route URLs
|
||||
|
||||
Routes mount at `/_emdash/api/plugins/<slug>/<route-name>`. Route names can include slashes for nested paths.
|
||||
|
||||
| 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` |
|
||||
| `analytics` | `events/recent` | `/_emdash/api/plugins/analytics/events/recent` |
|
||||
|
||||
<Aside type="caution">
|
||||
The plugin's `slug` (in `emdash-plugin.jsonc`) must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The slug fills a single Astro path segment in the route URL, so anything outside that pattern (scoped names, leading digits, dots, slashes) won't route, and storage-index provisioning rejects it too. Pair an unscoped `slug` with a scoped npm package name, the way first-party plugins do (`slug: "field-kit"`, package `@emdash-cms/plugin-field-kit`).
|
||||
</Aside>
|
||||
|
||||
## Authentication and CSRF
|
||||
|
||||
**Plugin routes are authenticated by default.** The dispatcher requires a session (or a token with the `admin` scope) before it'll call your handler:
|
||||
|
||||
- Read methods (`GET`, `HEAD`, `OPTIONS`) require the `plugins:read` permission.
|
||||
- Write methods (`POST`, `PUT`, `PATCH`, `DELETE`) require `plugins:manage`.
|
||||
- State-changing methods on private routes also require the `X-EmDash-Request: 1` CSRF header (the admin UI's `usePluginAPI()` hook sends it automatically; cookie-authed external callers need to set it themselves; token-authed requests are exempt).
|
||||
|
||||
To opt a route out of auth and CSRF, mark it `public: true`:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
track: {
|
||||
public: true,
|
||||
input: z.object({ event: z.string() }),
|
||||
handler: async (routeCtx, ctx) => {
|
||||
ctx.log.info("Tracked", { event: routeCtx.input.event });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Public routes skip authentication, scope, and CSRF entirely. Anyone on the internet can call them. Use `public: true` only for endpoints that need to accept external traffic — webhooks, public-facing search endpoints — and validate the input carefully.
|
||||
</Aside>
|
||||
|
||||
## Input validation
|
||||
|
||||
`input` accepts a Zod schema. The dispatcher parses the request body (POST/PUT/PATCH) or query string (GET/DELETE), validates it, and passes the typed result to your handler as `routeCtx.input`. Invalid input returns a 400 before your handler runs.
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
create: {
|
||||
input: z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
priority: z.enum(["low", "medium", "high"]).default("medium"),
|
||||
tags: z.array(z.string()).optional(),
|
||||
}),
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const { title, email, priority, tags } = routeCtx.input;
|
||||
|
||||
await ctx.storage.items.put(`item_${Date.now()}`, {
|
||||
title,
|
||||
email,
|
||||
priority,
|
||||
tags: tags ?? [],
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## Return values
|
||||
|
||||
Return any JSON-serialisable value. The dispatcher wraps it in EmDash's standard envelope (`{ success: true, data: <your value> }`) and serves it as `application/json`.
|
||||
|
||||
```typescript
|
||||
return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, count } }
|
||||
return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] }
|
||||
```
|
||||
|
||||
## Errors
|
||||
|
||||
Throw to return an error response. Anything that isn't a known plugin error returns a generic message — internal exceptions are masked rather than leaking stack traces or database errors:
|
||||
|
||||
```typescript
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const item = await ctx.storage.items.get(routeCtx.input.id);
|
||||
if (!item) {
|
||||
throw new Error("Item not found");
|
||||
}
|
||||
return item;
|
||||
},
|
||||
```
|
||||
|
||||
For a specific status code, throw a `Response`:
|
||||
|
||||
```typescript
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const item = await ctx.storage.items.get(routeCtx.input.id);
|
||||
if (!item) {
|
||||
throw new Response(JSON.stringify({ error: "Not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return item;
|
||||
},
|
||||
```
|
||||
|
||||
## HTTP methods
|
||||
|
||||
Routes respond to all methods. Branch on `routeCtx.request.method` if you need per-method behaviour:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
item: {
|
||||
input: z.object({ id: z.string() }),
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const { id } = routeCtx.input;
|
||||
|
||||
switch (routeCtx.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
|
||||
|
||||
`routeCtx.request` is a **`SandboxedRequest`**: a portable `{ url, method, headers }` record that behaves identically in-process and inside an isolate. `headers` is a `Record<string, string>` keyed by lowercased header name — index it by the lowercased name, or iterate with `Object.entries`. `url` is a string, so `new URL(request.url)` parses query params. `routeCtx.requestMeta` carries IP, user agent, and geo data normalised across platforms when available.
|
||||
|
||||
```typescript
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const { request, requestMeta } = routeCtx;
|
||||
|
||||
const auth = request.headers["authorization"]; // lowercased key, no .get()
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page");
|
||||
|
||||
ctx.log.info("Request", { meta: requestMeta });
|
||||
|
||||
if (request.method !== "POST") {
|
||||
throw new Response("POST required", { status: 405 });
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
## Common patterns
|
||||
|
||||
### Settings via KV
|
||||
|
||||
Sandboxed plugins read and write settings through the KV store, conventionally under a `settings:` prefix. The auto-generated `settingsSchema` form is native-only — for sandboxed plugins, expose the read/write through routes and render the form in [Block Kit](/plugins/creating-plugins/block-kit/).
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
settings: {
|
||||
handler: async (_routeCtx, 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 (routeCtx, ctx) => {
|
||||
for (const [key, value] of Object.entries(routeCtx.input)) {
|
||||
if (value !== undefined) {
|
||||
await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
}
|
||||
return { success: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
### Paginated list
|
||||
|
||||
Return cursor-based pagination from a storage query — the response shape matches what the rest of EmDash uses:
|
||||
|
||||
```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 (routeCtx, ctx) => {
|
||||
const { limit, cursor, status } = routeCtx.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 a request to an external service through `ctx.http` (requires `network:request` capability and an entry in `allowedHosts`):
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
forecast: {
|
||||
input: z.object({ city: z.string() }),
|
||||
handler: async (routeCtx, ctx) => {
|
||||
if (!ctx.http) throw new Error("Network capability not granted");
|
||||
|
||||
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=${routeCtx.input.city}`,
|
||||
{ headers: { "X-API-Key": apiKey } },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
## Calling routes from the admin UI
|
||||
|
||||
Use `usePluginAPI()` from the admin package — it adds the `X-EmDash-Request` CSRF header and the plugin id prefix automatically:
|
||||
|
||||
```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");
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Calling routes externally
|
||||
|
||||
Public routes are callable directly:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"event": "pageview"}'
|
||||
```
|
||||
|
||||
Private routes need session credentials or an API token with the `admin` scope:
|
||||
|
||||
```bash
|
||||
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Hello", "email": "user@example.com"}'
|
||||
```
|
||||
|
||||
## Route context reference
|
||||
|
||||
```typescript
|
||||
// What sandboxed route handlers receive as their two arguments
|
||||
|
||||
interface SandboxedRequest {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Record<string, string>; // lowercased keys
|
||||
}
|
||||
|
||||
interface SandboxedRouteContext {
|
||||
input: unknown; // narrow with the `input` Zod schema at the route level
|
||||
request: SandboxedRequest;
|
||||
requestMeta?: unknown;
|
||||
}
|
||||
|
||||
interface PluginContext {
|
||||
plugin: { id: string; version: string };
|
||||
storage: PluginStorage;
|
||||
kv: KVAccess;
|
||||
log: LogAccess;
|
||||
site: SiteInfo;
|
||||
url(path: string): string;
|
||||
cron?: CronAccess;
|
||||
content?: ContentAccess; // when content:read or content:write declared
|
||||
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
|
||||
media?: MediaAccess; // when media:read or media:write declared
|
||||
http?: HttpAccess; // when network:request declared
|
||||
users?: UserAccess; // when users:read declared
|
||||
email?: EmailAccess; // when email:send declared and provider configured
|
||||
}
|
||||
```
|
||||
|
||||
Native plugins receive a single `RouteContext` argument that combines the two — see [Creating native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) if you're going that route.
|
||||
@@ -0,0 +1,160 @@
|
||||
---
|
||||
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 can ship their own React components for admin pages. Block Kit is the admin UI system for sandboxed plugins, where shipping React would break sandbox isolation.
|
||||
</Aside>
|
||||
|
||||
<Aside type="tip">
|
||||
Block Kit elements are also used for [Portable Text block editing fields](/plugins/creating-native-plugins/portable-text-components/). 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. The 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
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
interface BlockInteraction {
|
||||
type: "page_load" | "block_action" | "form_submit";
|
||||
page?: string;
|
||||
action_id?: string;
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default {
|
||||
routes: {
|
||||
admin: {
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const interaction = routeCtx.input as BlockInteraction;
|
||||
|
||||
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" },
|
||||
};
|
||||
}
|
||||
|
||||
return { blocks: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
The route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`). `satisfies SandboxedPlugin` infers both.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
title: Capabilities and security
|
||||
description: Declare what your plugin needs, and understand what the sandbox enforces.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to **declare a capability** in its [manifest](/plugins/creating-plugins/manifest/). The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn't declare `content:read` doesn't get a `ctx.content`, and one that didn't declare `network:request` doesn't get `ctx.http`.
|
||||
|
||||
This page covers what each capability grants, how the sandbox enforces them, and what's not enforceable.
|
||||
|
||||
## Declaring capabilities
|
||||
|
||||
Capabilities live in `emdash-plugin.jsonc`, alongside `slug` and the rest of the trust contract:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"slug": "plugin-hello",
|
||||
// ...identity + profile...
|
||||
|
||||
"capabilities": ["content:read", "network:request"],
|
||||
"allowedHosts": ["api.example.com"]
|
||||
}
|
||||
```
|
||||
|
||||
Declare only what the plugin actually needs. Capability declarations are also what the marketplace shows site operators on the consent dialog — extra capabilities are friction at install time and a security flag in audits.
|
||||
|
||||
## Capability reference
|
||||
|
||||
| Capability | Grants access to |
|
||||
| ----------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
|
||||
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` (implies `content:read`) |
|
||||
| `taxonomies:read` | `ctx.taxonomies.getAll()`, `ctx.taxonomies.getTerms()`, `ctx.taxonomies.getEntryTerms()` |
|
||||
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
|
||||
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` (implies `media:read`) |
|
||||
| `network:request` | `ctx.http.fetch()` — restricted to `allowedHosts` |
|
||||
| `network:request:unrestricted` | `ctx.http.fetch()` with no host restriction (for user-configured URLs only) |
|
||||
| `users:read` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
|
||||
| `email:send` | `ctx.email.send()` (requires a configured email provider plugin) |
|
||||
| `hooks.email-transport:register` | Allows registering the exclusive `email:deliver` hook (transport providers) |
|
||||
| `hooks.email-events:register` | Allows registering `email:beforeSend` / `email:afterSend` hooks |
|
||||
| `hooks.page-fragments:register` | Allows registering the `page:fragments` hook (native plugins only) |
|
||||
|
||||
A few things worth knowing:
|
||||
|
||||
- **Implications.** `content:write` automatically implies `content:read`; `media:write` implies `media:read`; `network:request:unrestricted` implies `network:request`. You don't need to list both.
|
||||
- **Taxonomies are a separate, read-only surface.** `taxonomies:read` grants access to taxonomy definitions, their terms, and the terms assigned to an entry via `ctx.taxonomies`. It is independent of `content:read` — declare both if the plugin reads content *and* its classification. There is no taxonomy *write* access from plugins.
|
||||
- **`network:request:unrestricted` exists for user-configured URLs.** A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use `network:request` + `allowedHosts`.
|
||||
- **`email:send` is gated by configuration, not just the capability.** A plugin can declare `email:send`, but `ctx.email` will only be populated if some other plugin has registered an `email:deliver` transport.
|
||||
|
||||
## Network host allowlists
|
||||
|
||||
Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. Wildcards are supported for subdomains:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
"capabilities": ["network:request"],
|
||||
"allowedHosts": [
|
||||
"api.example.com", // exact host
|
||||
"*.cdn.example.com" // any subdomain of cdn.example.com
|
||||
]
|
||||
```
|
||||
|
||||
The bridge checks the request URL's host against the allowlist before forwarding the request. A request to a host that wasn't declared throws inside the plugin without ever leaving the sandbox.
|
||||
|
||||
`network:request:unrestricted` skips the allowlist check entirely. It's intended for plugins where the operator configures the destination URL at runtime (webhook senders, generic HTTP forwarders). Avoid it for plugins where the destination is part of the plugin's design — declare `network:request` with explicit hosts instead, so the consent dialog tells operators exactly where the plugin is going to call.
|
||||
|
||||
## What the sandbox enforces
|
||||
|
||||
When a sandbox runner is active, the runtime enforces:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.taxonomies`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.
|
||||
|
||||
2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's slug. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared in the manifest.
|
||||
|
||||
3. **Network isolation.** Direct `fetch()` and other network primitives are blocked by the runner. The only way to reach the network is `ctx.http.fetch()`, which goes through the bridge's host validation.
|
||||
|
||||
4. **No host bindings.** Sandboxed plugins don't see environment variables, the filesystem, or any platform bindings — even if your host worker has them. The plugin runtime is a clean isolate with only the bridge and the declared capabilities.
|
||||
|
||||
5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). The Node.js workerd runner (`@emdash-cms/sandbox-workerd`) enforces wall-clock time via `Promise.race`; CPU and memory limits are Cloudflare platform features and are not enforced by standalone workerd. Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that.
|
||||
|
||||
</Steps>
|
||||
|
||||
## What the sandbox doesn't enforce
|
||||
|
||||
A few things the capability system doesn't and can't cover:
|
||||
|
||||
- **Behaviour within a granted capability.** A plugin with `content:write` can edit any content, not only its own. Capabilities are coarse — they say "this plugin can write content," not "this plugin can write only the content it created." Audit-time review is the only check on what a plugin actually does within its grant.
|
||||
- **Operator trust on Node.js.** When the configured sandbox runner reports unavailable (no Cloudflare Worker Loader, no Node-side runner installed, etc.), `sandboxed: []` plugins are skipped at startup. You can move them into `plugins: []` to run them in-process — but then there's no V8 isolate, no resource limits, and the plugin can call `fetch()` directly or read environment variables. Treat that as native-level trust.
|
||||
- **Side channels.** Timing, log output, and stored data are all visible to anyone with appropriate access to the host environment. Don't use the sandbox as a confidentiality boundary against the operator running it.
|
||||
|
||||
## Capability consent
|
||||
|
||||
When an operator installs a sandboxed plugin from the marketplace, EmDash shows a consent dialog listing the declared capabilities. Updates that add capabilities — for example, a plugin that previously only read content now wants to make network requests — surface as a capability diff and require fresh approval before the new version takes effect.
|
||||
|
||||
This is why declaring extra capabilities matters even if you "might use them later". They show up as friction at every install and update, and security audits flag plugins that ask for more than they obviously need. List exactly what the plugin uses, and add new capabilities in a real version when the plugin actually starts using them.
|
||||
|
||||
## Bundle-time validation
|
||||
|
||||
`emdash-plugin bundle` and `emdash-plugin publish` perform additional checks:
|
||||
|
||||
- Every declared capability must be in the recognised set (typos fail the build).
|
||||
- `network:request` requires a non-empty `allowedHosts`; `network:request:unrestricted` requires it to be empty. See [the manifest reference](/plugins/creating-plugins/manifest/#capabilities).
|
||||
- The bundled `backend.js` can't import Node.js built-ins (`fs`, `path`, `child_process`, etc.) — sandbox runtimes don't provide them.
|
||||
|
||||
See [Bundling and publishing](/plugins/creating-plugins/publishing/) for the full list of checks.
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Choosing a plugin format
|
||||
description: Pick between sandboxed and native plugins before you start writing code.
|
||||
---
|
||||
|
||||
import { Aside, CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash plugins ship in one of two formats: **sandboxed** or **native**. The choice affects how the plugin is installed, what enforcement it gets at runtime, and which features are available.
|
||||
|
||||
**Default to sandboxed.** Sandboxed plugins can be published to the marketplace and installed from the admin UI with one click. Native plugins require a code change, an `npm install`, and a redeploy on every site that wants them. Sandboxed is what end users want.
|
||||
|
||||
Choose native only when you need a feature the sandbox can't provide.
|
||||
|
||||
## At a glance
|
||||
|
||||
| | Sandboxed | Native |
|
||||
| ----------------------------------- | -------------------------------------------------- | ------------------------------------- |
|
||||
| Authoring shape | `emdash-plugin.jsonc` + `src/plugin.ts` | `definePlugin()` descriptor |
|
||||
| Install method | One-click from the admin marketplace | `npm install` + edit `astro.config` |
|
||||
| Runs in | An isolated runtime provided by a sandbox runner | Same process as your Astro site |
|
||||
| Capabilities | Enforced by the sandbox bridge | Enforced in-process by the same bridge |
|
||||
| Resource limits | Enforced by the runner — typically CPU, subrequests, wall-time, memory | None |
|
||||
| Network access | `ctx.http` only, restricted to `allowedHosts` | `ctx.http` only, restricted to `allowedHosts` |
|
||||
| Direct `fetch()` / `process.env` | Blocked by the runner | Possible (plugin code shares the runtime) |
|
||||
| Distribution | `.tar.gz` bundle on the marketplace | npm package |
|
||||
| Admin UI | Block Kit (JSON-described) routes | React components, or Block Kit |
|
||||
| Settings UI | Block Kit page + KV reads | `admin.settingsSchema` (auto-form) or Block Kit |
|
||||
| Portable Text rendering components | Not available | `componentsEntry` provides Astro components |
|
||||
| Page metadata contributions | `page:metadata` hook — meta/property tags, allowlisted `<link>` rels, JSON-LD | `page:metadata` hook (same surface) |
|
||||
| Page fragment injection | Not available — meta/JSON-LD only via `page:metadata` | `page:fragments` hook — inline scripts, external scripts, raw HTML |
|
||||
| Constructor options | None — read settings from KV at runtime | `options` on the descriptor |
|
||||
|
||||
## What you give up by going native
|
||||
|
||||
Native plugins look like a more powerful version of the same thing, and they are — but the cost is steep:
|
||||
|
||||
- **No marketplace.** Every site has to install your npm package, edit `astro.config.mjs`, and redeploy.
|
||||
- **No isolation.** A bug in your plugin can crash the host process or burn its CPU budget. An unhandled rejection in a hook can take the surrounding request down with it.
|
||||
- **Trust burden on the user.** Native plugins have the same access as the host site. End users can't audit them through capability declarations alone.
|
||||
|
||||
If your plugin can do its job in the sandbox, it should.
|
||||
|
||||
## When to go native
|
||||
|
||||
There are three reasons to choose native, and they're all about features that need build-time integration with the host site:
|
||||
|
||||
1. **Custom React admin pages or widgets.** Sandboxed plugins describe their admin UI with Block Kit — a JSON schema that the admin renders on the plugin's behalf. If you need full React (custom hooks, third-party components, complex state), you need native.
|
||||
2. **Astro components for rendering Portable Text blocks on the public site.** A sandboxed plugin can declare a custom block type, but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a `componentsEntry`.
|
||||
3. **Injecting raw HTML, scripts, or stylesheets into public pages.** The `page:fragments` hook ships first-party code to visitors' browsers — outside any sandbox boundary. It's restricted to native plugins. Sandboxed plugins can still contribute to public pages through the `page:metadata` hook, which covers a lot of real use cases:
|
||||
|
||||
- `meta` tags (`name` + `content`) — SEO descriptions, robots directives, Twitter cards
|
||||
- `property` tags — OpenGraph and other property-based meta
|
||||
- `link` tags with a security-locked rel allowlist (`canonical`, `alternate`, `author`, `license`, `nlweb`, `site.standard.document`) — `stylesheet`, `prefetch`, and similar resource-loading rels are deliberately not allowed
|
||||
- JSON-LD graphs
|
||||
|
||||
If your "page injection" need is structured data or SEO metadata, stay sandboxed and use `page:metadata`. If you actually need to ship JavaScript or HTML into the visitor's browser, that's the case for going native.
|
||||
|
||||
If you're not sure, go sandboxed. You can always migrate to native later — but the reverse is harder, because native-only features have no sandbox equivalent.
|
||||
|
||||
<Aside type="note" title="Same hooks, same context">
|
||||
Sandboxed and native plugins use the same hook names, the same `PluginContext` API, and the same
|
||||
storage and KV helpers. The format choice changes how the plugin is packaged and what extra
|
||||
features are available — not the day-to-day code you write inside hooks and routes.
|
||||
</Aside>
|
||||
|
||||
## Sandbox runners and platform support
|
||||
|
||||
The sandbox itself is pluggable. EmDash exposes a `sandboxRunner` config option and the runner decides how plugin code is isolated — there's nothing Cloudflare-specific in the plugin format itself.
|
||||
|
||||
The runner most sites use today is `sandbox()` from `@emdash-cms/cloudflare`, which uses Cloudflare Workers' [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). Worker Loader caches the V8 isolate per plugin id so the isolate cold-start cost is only paid once; the runner constructs a fresh worker stub and bridge bindings on each invocation, since stubs and bindings are tied to the calling request's I/O context. Runners for other platforms (Node.js via workerd, and potentially Deno) are in development.
|
||||
|
||||
If no runner is configured, or if the configured runner reports as unavailable on the current platform, plugins listed under `sandboxed: []` are skipped at startup with a debug-level log.
|
||||
|
||||
If you want a sandboxed plugin to run on a platform without a sandbox runner, move it from `sandboxed: []` into the `plugins: []` array — it'll execute in-process. Capability declarations are still honoured (the same `PluginContext` factory gates `ctx.content`, `ctx.http`, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call `fetch()` directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes.
|
||||
|
||||
## Next
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Your first sandboxed plugin"
|
||||
href="/plugins/creating-plugins/your-first-plugin/"
|
||||
description="Build, register, and run a hello-world sandboxed plugin."
|
||||
/>
|
||||
<LinkCard
|
||||
title="When to go native"
|
||||
href="/plugins/creating-native-plugins/your-first-native-plugin/"
|
||||
description="The three native-only features, in detail."
|
||||
/>
|
||||
</CardGrid>
|
||||
@@ -0,0 +1,118 @@
|
||||
---
|
||||
title: The emdash-plugin CLI
|
||||
description: init, build, dev, bundle, validate, publish — the plugin authoring toolchain.
|
||||
---
|
||||
|
||||
import { Steps } from "@astrojs/starlight/components";
|
||||
|
||||
`@emdash-cms/plugin-cli` is the authoring toolchain: scaffold, build, watch, validate, bundle, publish, plus identity and discovery. The binary is `emdash-plugin`.
|
||||
|
||||
## Commands
|
||||
|
||||
The CLI provides the following commands:
|
||||
|
||||
```text
|
||||
emdash-plugin init [name] Scaffold a new sandboxed plugin
|
||||
emdash-plugin build Build dist/ (plugin.mjs, manifest.json, index.mjs)
|
||||
emdash-plugin dev Watch sources and rebuild on change
|
||||
emdash-plugin bundle Pack dist/ + assets into a registry tarball
|
||||
emdash-plugin validate [path] Validate emdash-plugin.jsonc against the schema
|
||||
emdash-plugin publish --url <url> Publish a release pointing at a hosted tarball
|
||||
emdash-plugin login <handle-or-did> Sign in with your Atmosphere account
|
||||
emdash-plugin logout [--did <did>] Revoke the active session
|
||||
emdash-plugin whoami Show stored sessions
|
||||
emdash-plugin switch <did> Switch the active publisher session
|
||||
emdash-plugin search <query> Free-text registry search
|
||||
emdash-plugin info <handle-or-did> <slug> Show package details
|
||||
```
|
||||
|
||||
The non-interactive output commands (`whoami`, `validate`, `search`, `info`, `login`, `publish`) accept `--json` for machine-readable output. Discovery commands (`search`, `info`) accept `--registry-url <url>` (or `EMDASH_REGISTRY_URL`).
|
||||
|
||||
The following example shows the two scripts most plugins add to `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"scripts": {
|
||||
"build": "emdash-plugin build",
|
||||
"dev": "emdash-plugin dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `init`
|
||||
|
||||
Create a new plugin with `init`:
|
||||
|
||||
```sh
|
||||
npx @emdash-cms/plugin-cli init my-plugin
|
||||
```
|
||||
|
||||
This scaffolds a self-contained plugin: `emdash-plugin.jsonc`, `src/plugin.ts` (one example route in the `satisfies SandboxedPlugin` shape), `package.json`, `tsconfig.json`, a test, a README, and `.gitignore`. A slug is the only required input. A scaffold created from just a slug is a valid starting point: the manifest carries `TODO:` comments for the few fields to fill in — publisher, author, and security contact — before the plugin will load or publish.
|
||||
|
||||
## `build`
|
||||
|
||||
`build` reads `emdash-plugin.jsonc`, `src/plugin.ts`, and an optional sibling `package.json`, and emits the following files:
|
||||
|
||||
| Artifact | What it is |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `dist/plugin.mjs` (+ `dist/plugin.d.mts`) | The hooks and routes. Loaded in-process (`plugins: []`) and by the sandbox loader (`sandboxed: []`). |
|
||||
| `dist/manifest.json` | The plugin's manifest, including the hooks and routes read from `src/plugin.ts`. `bundle` includes this file as-is; npm consumers read it without parsing the JSONC source. |
|
||||
| `dist/index.mjs` (+ `dist/index.d.mts`) | The descriptor module a site imports in `astro.config.mjs`. Emitted only when a sibling `package.json` exists; registry-only plugins skip it, since nothing imports it. |
|
||||
|
||||
`dist/` is build output. Do not commit it. The scaffold's `.gitignore` excludes it, and installs rebuild it.
|
||||
|
||||
## `dev`
|
||||
|
||||
Watches `src/**`, `emdash-plugin.jsonc`, and `package.json`, debouncing rebuilds at 150 ms. Rebuilds are serialised. On a **failed** rebuild it leaves the last good `dist/` in place, so a site importing the plugin via a workspace/file link keeps working until the next successful build. Ctrl-C drains cleanly.
|
||||
|
||||
Develop against a real site by running `pnpm dev` here and `pnpm add file:../path/to/this` in the site, then importing the plugin's default export into `emdash({ sandboxed: [...] })`.
|
||||
|
||||
## `validate`
|
||||
|
||||
```sh
|
||||
emdash-plugin validate # ./emdash-plugin.jsonc
|
||||
emdash-plugin validate path/ # a specific directory
|
||||
```
|
||||
|
||||
Offline schema check with `tsc`-style `file:line:column` diagnostics, including the manifest's cross-field rules. No network. Good as a pre-commit or CI gate. See [the manifest reference](/plugins/creating-plugins/manifest/).
|
||||
|
||||
## `bundle`
|
||||
|
||||
`bundle` is a thin packaging step on top of `build`:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Runs `build` to produce `dist/`.
|
||||
2. Validates the bundle: no Node-builtin imports, no oversized files, capability sanity.
|
||||
3. Collects optional assets — README, icon, screenshots.
|
||||
4. Tarballs. Inside the tarball, `plugin.mjs` is packed as `backend.js` (the filename the registry expects). The output is `dist/<slug>-<version>.tar.gz`.
|
||||
|
||||
</Steps>
|
||||
|
||||
`--validate-only` skips tarball creation but still produces the `dist/` artifacts — "validate" implies "build first".
|
||||
|
||||
## `publish`
|
||||
|
||||
The CLI does not host artifacts; you do, anywhere public.
|
||||
|
||||
```sh
|
||||
emdash-plugin login # if not already logged in
|
||||
emdash-plugin bundle # produces dist/<slug>-<version>.tar.gz
|
||||
# upload that tarball to a public URL, then:
|
||||
emdash-plugin publish --url https://your-host/my-plugin-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
`publish` reads the manifest for profile fields and enforces [publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning). On first publish, pass `--license` and a security contact (or keep them in the manifest). Explicit flags override manifest values, which is useful in CI; `--no-manifest` opts out entirely.
|
||||
|
||||
Full walkthrough: [Bundling and publishing](/plugins/creating-plugins/publishing/).
|
||||
|
||||
## Programmatic API
|
||||
|
||||
```ts
|
||||
import { buildPlugin, bundlePlugin } from "@emdash-cms/plugin-cli";
|
||||
|
||||
await buildPlugin({ dir: "./my-plugin" });
|
||||
const result = await bundlePlugin({ dir: "./my-plugin" });
|
||||
```
|
||||
|
||||
For discovery and credential helpers, import from `@emdash-cms/registry-client`.
|
||||
@@ -0,0 +1,417 @@
|
||||
---
|
||||
title: Hooks
|
||||
description: Run code in response to content, media, lifecycle, and page-render 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, and they're declared at plugin definition time — there's no dynamic registration at runtime.
|
||||
|
||||
This page covers sandboxed plugins. Hooks work identically in native plugins; native plugins can additionally register `page:fragments`.
|
||||
|
||||
## Hook signature
|
||||
|
||||
Every hook handler takes two arguments:
|
||||
|
||||
```typescript
|
||||
async (event, ctx) => ReturnType;
|
||||
```
|
||||
|
||||
- `event` — data about what just happened (content being saved, media uploaded, lifecycle transition, etc.)
|
||||
- `ctx` — the [`PluginContext`](/plugins/creating-plugins/your-first-plugin/) with storage, KV, logging, and capability-gated APIs
|
||||
|
||||
`satisfies SandboxedPlugin` on the default export infers `event` from the hook name (the full canonical event type) and `ctx` as `PluginContext`, so handlers need no parameter annotations. To reference an event type by name in a helper, import it from `emdash/plugin`.
|
||||
|
||||
## Hook configuration
|
||||
|
||||
A hook can be declared as a bare handler or wrapped in a config object:
|
||||
|
||||
<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 to the operation succeeding.
|
||||
</Aside>
|
||||
|
||||
## Lifecycle hooks
|
||||
|
||||
Run during plugin installation, activation, deactivation, and removal.
|
||||
|
||||
### `plugin:install`
|
||||
|
||||
Runs once when the plugin is first added to a site.
|
||||
|
||||
```typescript
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
ctx.log.info("Installing plugin...");
|
||||
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");
|
||||
},
|
||||
```
|
||||
|
||||
**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) {
|
||||
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
|
||||
|
||||
Run during create, update, and delete operations on site content.
|
||||
|
||||
### `content:beforeSave`
|
||||
|
||||
Runs before content is saved. Return modified content, or `void` to leave it unchanged. Throw to cancel.
|
||||
|
||||
```typescript
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
const { content, collection } = event;
|
||||
|
||||
if (collection === "posts" && !content.title) {
|
||||
throw new Error("Posts require a title");
|
||||
}
|
||||
|
||||
if (typeof content.slug === "string") {
|
||||
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
```
|
||||
|
||||
**Event:** `{ content, collection, isNew }` — **Returns:** modified content or `void`.
|
||||
|
||||
### `content:afterSave`
|
||||
|
||||
Runs after content is successfully saved. Use for side effects like notifications, logging, or external syncs.
|
||||
|
||||
```typescript
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info(`${event.isNew ? "Created" : "Updated"} ${event.collection}/${event.content.id}`);
|
||||
|
||||
if (ctx.http) {
|
||||
await ctx.http.fetch("https://api.example.com/webhook", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event: "content:save", id: event.content.id }),
|
||||
});
|
||||
}
|
||||
},
|
||||
```
|
||||
|
||||
**Event:** `{ content, collection, isNew }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:beforeDelete`
|
||||
|
||||
Runs before content is deleted. Return `false` to cancel; `true` or `void` allows it.
|
||||
|
||||
```typescript
|
||||
"content:beforeDelete": async (event, ctx) => {
|
||||
if (event.collection === "pages" && event.id === "home") {
|
||||
ctx.log.warn("Cannot delete home page");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
```
|
||||
|
||||
**Event:** `{ id, collection }` — **Returns:** `boolean | void`
|
||||
|
||||
### `content:afterDelete`
|
||||
|
||||
Runs after content is successfully deleted.
|
||||
|
||||
```typescript
|
||||
"content:afterDelete": async (event, ctx) => {
|
||||
await ctx.storage.cache.delete(`${event.collection}:${event.id}`);
|
||||
},
|
||||
```
|
||||
|
||||
**Event:** `{ id, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterPublish`
|
||||
|
||||
Runs after content is promoted from draft to live. Requires `content:read` capability.
|
||||
|
||||
**Event:** `{ content, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterUnpublish`
|
||||
|
||||
Runs after content is reverted from live to draft. Requires `content:read` capability.
|
||||
|
||||
**Event:** `{ content, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterRestore`
|
||||
|
||||
Runs after trashed content is restored. Requires `content:read` capability.
|
||||
|
||||
**Event:** `{ content, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterSchedule`
|
||||
|
||||
Runs after content is scheduled for future publishing. Requires `content:read` capability.
|
||||
|
||||
**Event:** `{ content, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterUnschedule`
|
||||
|
||||
Runs after scheduled content is unscheduled. Requires `content:read` capability.
|
||||
|
||||
**Event:** `{ content, collection }` — **Returns:** `Promise<void>`
|
||||
|
||||
## Media hooks
|
||||
|
||||
### `media:beforeUpload`
|
||||
|
||||
Runs before a file is uploaded. Return modified file metadata or throw to cancel.
|
||||
|
||||
```typescript
|
||||
"media:beforeUpload": async (event, ctx) => {
|
||||
if (!event.file.type.startsWith("image/")) {
|
||||
throw new Error("Only images are allowed");
|
||||
}
|
||||
if (event.file.size > 10 * 1024 * 1024) {
|
||||
throw new Error("File too large");
|
||||
}
|
||||
return { ...event.file, name: `${Date.now()}-${event.file.name}` };
|
||||
},
|
||||
```
|
||||
|
||||
**Event:** `{ file: { name, type, size } }` — **Returns:** modified file or `void`
|
||||
|
||||
### `media:afterUpload`
|
||||
|
||||
Runs after a file is successfully uploaded.
|
||||
|
||||
**Event:** `{ media: { id, filename, mimeType, size, url, createdAt } }` — **Returns:** `Promise<void>`
|
||||
|
||||
## Public-page hooks
|
||||
|
||||
These let plugins contribute to rendered public pages. Templates opt in by including the `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components from `emdash/ui`.
|
||||
|
||||
### `page:metadata`
|
||||
|
||||
Contributes typed metadata to `<head>` — meta tags, OpenGraph properties, allowlisted `<link>` rels, and JSON-LD. **Available to both sandboxed and native plugins.** 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 kinds:**
|
||||
|
||||
| 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 `rel` is restricted to a security-locked allowlist (`canonical`, `alternate`, `author`, `license`, `nlweb`, `site.standard.document`); `href` must be HTTP or HTTPS.
|
||||
|
||||
### `page:fragments`
|
||||
|
||||
Contributes raw HTML, scripts, or stylesheets to page insertion points. **Native plugins only.**
|
||||
|
||||
Sandboxed plugins can't use this hook because its output runs as first-party code in the visitor's browser, outside any sandbox boundary. For sandbox-safe page contributions, use `page:metadata`. See [Native plugins: page fragments](/plugins/creating-native-plugins/page-fragments/) if you need this surface.
|
||||
|
||||
## 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, handler: async () => {} }
|
||||
|
||||
// Plugin B
|
||||
"content:afterSave": { priority: 100, handler: async () => {} }
|
||||
|
||||
// Plugin C
|
||||
"content:afterSave": {
|
||||
priority: 200,
|
||||
dependencies: ["plugin-a"], // waits for A even if its priority would normally be later
|
||||
handler: async () => {},
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
When a hook throws or times out:
|
||||
|
||||
- **`errorPolicy: "abort"`** — the entire pipeline stops and the originating operation may fail.
|
||||
- **`errorPolicy: "continue"`** — the error is logged and remaining hooks still run.
|
||||
|
||||
```typescript
|
||||
"content:afterSave": {
|
||||
timeout: 5000,
|
||||
errorPolicy: "continue",
|
||||
handler: async (event, ctx) => {
|
||||
await ctx.http!.fetch("https://unreliable-api.com/notify");
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
`errorPolicy: "continue"` is right for analytics, notifications, and external syncs — the originating save still succeeds even if your hook fails.
|
||||
</Aside>
|
||||
|
||||
## Timeouts
|
||||
|
||||
Hooks default to 5000ms. Bump the timeout for slower work:
|
||||
|
||||
```typescript
|
||||
"content:afterSave": {
|
||||
timeout: 30000,
|
||||
handler: async (event, ctx) => {
|
||||
// Long-running operation
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
The configured `timeout` is enforced at the EmDash runner level. The sandbox runner may also enforce its own resource limits (CPU, subrequests, wall-clock) that can terminate a hook before its `timeout` fires. See [Capabilities and security](/plugins/creating-plugins/capabilities/).
|
||||
</Aside>
|
||||
|
||||
## Hook 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 |
|
||||
| `content:afterRestore` | After content restore | `void` | No |
|
||||
| `content:afterSchedule` | After content schedule | `void` | No |
|
||||
| `content:afterUnschedule` | After content unschedule | `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.
|
||||
@@ -0,0 +1,193 @@
|
||||
---
|
||||
title: The plugin manifest
|
||||
description: Reference for emdash-plugin.jsonc — identity, trust contract, profile fields, and publisher pinning.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
Every sandboxed plugin has an `emdash-plugin.jsonc` next to its `package.json`. It is hand-edited and holds the plugin's identity, its trust contract (capabilities, hosts, storage), and the profile fields the registry shows. `emdash-plugin init` scaffolds one; the CLI reads `./emdash-plugin.jsonc` automatically for `build`, `dev`, `validate`, `bundle`, and `publish`.
|
||||
|
||||
The file is **JSONC**: comments and trailing commas are allowed.
|
||||
|
||||
The following example shows a complete manifest for an image-gallery plugin:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
||||
|
||||
"slug": "gallery",
|
||||
"publisher": "did:plc:abc123def456",
|
||||
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe", "url": "https://example.com" },
|
||||
"security": { "email": "security@example.com" },
|
||||
|
||||
// Optional profile
|
||||
"name": "Gallery",
|
||||
"description": "Image gallery block for EmDash.",
|
||||
"keywords": ["gallery", "images"],
|
||||
"repo": "https://github.com/example/plugin-gallery",
|
||||
|
||||
// Trust contract
|
||||
"capabilities": ["content:read"],
|
||||
"allowedHosts": [],
|
||||
"storage": {}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip" title="$schema gives you IDE completion">
|
||||
The bundled JSON Schema drives hover hints and validation in editors that
|
||||
honour `$schema` (VS Code, JetBrains). Keep the `$schema` line; the CLI
|
||||
strips it before validating. The editor check is looser than
|
||||
`emdash-plugin validate`, which is the authoritative check.
|
||||
</Aside>
|
||||
|
||||
## Identity
|
||||
|
||||
| Field | Required | Notes |
|
||||
| ----------- | -------- | ---------------------------------------------------------------------------------------------- |
|
||||
| `slug` | Yes | URL-safe id within the publisher's namespace. `/^[a-z][a-z0-9_-]*$/`, max 64 chars. |
|
||||
| `publisher` | Yes | Your [Atmosphere account](/plugins/creating-plugins/publishing/#your-atmosphere-account)'s DID or handle. See [Publisher pinning](#publisher-pinning). |
|
||||
| `version` | No | Semver 2.0 without build-metadata. Usually omit it — see below. |
|
||||
|
||||
`slug` and `publisher` together are the package's identity. EmDash derives the package's full identifier from them automatically.
|
||||
|
||||
### `version` lives in `package.json`
|
||||
|
||||
The build reconciles the manifest's `version` against `package.json#version`:
|
||||
|
||||
- Both set and equal → fine.
|
||||
- Both set and different → hard error.
|
||||
- One set → that value wins.
|
||||
- Neither set → hard error.
|
||||
|
||||
The recommended pattern for an npm-distributed plugin is to **omit `version` from the manifest** and let `package.json` be the single source of truth (your release tooling already bumps it there). Registry-only plugins with no `package.json` must set `version` in the manifest — there is nowhere else for it to live.
|
||||
|
||||
## Profile
|
||||
|
||||
These feed the registry listing. `license`, an author (`author` or `authors`), and a security contact (`security` or `securityContacts`) are required; the rest are optional.
|
||||
|
||||
| Field | Required | Notes |
|
||||
| -------------------------- | -------- | ------------------------------------------------------------------ |
|
||||
| `license` | Yes | SPDX expression (`"MIT"`, `"Apache-2.0"`, `"MIT OR Apache-2.0"`). Used on first publish; the existing profile wins on later publishes. |
|
||||
| `author` / `authors` | Yes | One of the two. `author: { name, url?, email? }` for a single author; `authors: [...]` (≤ 32) for several. Setting both is an error. |
|
||||
| `security` / `securityContacts` | Yes | One of the two. Each contact needs at least one of `email` or `url`. `securityContacts: [...]` (≤ 8) for several. Setting both is an error. |
|
||||
| `name` | No | Display name. Defaults to the slug. |
|
||||
| `description` | No | Keep it short (around 140 characters). Long values may be truncated in lists. |
|
||||
| `keywords` | No | ≤ 5 entries. |
|
||||
| `repo` | No | `https://` URL of the source repo. |
|
||||
|
||||
Use the singular `author` / `security` form unless you genuinely have multiple — it is the common case and the scaffold emits it.
|
||||
|
||||
## Trust contract
|
||||
|
||||
The trust contract is `capabilities`, `allowedHosts`, and `storage`. All three default to empty, so a plugin that needs no extra privileges can omit them entirely.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"capabilities": ["network:request", "content:read"],
|
||||
"allowedHosts": ["api.example.com", "*.cdn.example.com"],
|
||||
"storage": {
|
||||
"events": { "indexes": ["timestamp"] },
|
||||
"submissions": { "indexes": ["email"], "uniqueIndexes": ["token"] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="caution" title="Changing the trust contract requires a version bump">
|
||||
Installed sites consented to the capabilities, hosts, and storage of the
|
||||
version they have. Changing any of these without bumping `version` would
|
||||
let new behaviour slip past that consent. Bump **major** for a broadened
|
||||
trust contract.
|
||||
</Aside>
|
||||
|
||||
### Capabilities
|
||||
|
||||
The recognised names:
|
||||
|
||||
| Capability | Grants |
|
||||
| ----------------------------------- | --------------------------------------------------- |
|
||||
| `content:read` / `content:write` | Read / mutate site content via `ctx`. |
|
||||
| `taxonomies:read` | Read taxonomy definitions and terms (read-only). |
|
||||
| `media:read` / `media:write` | Read / write media. |
|
||||
| `users:read` | Read user records. |
|
||||
| `email:send` | Send email via `ctx`. |
|
||||
| `network:request` | Outbound HTTP via `ctx.http`, restricted to `allowedHosts`. |
|
||||
| `network:request:unrestricted` | Outbound HTTP to any host. Used instead of `network:request`. |
|
||||
| `hooks.email-transport:register` | Register an email transport hook. |
|
||||
| `hooks.email-events:register` | Register email lifecycle hooks. |
|
||||
| `hooks.page-fragments:register` | Register a `page:fragments` hook (native only). |
|
||||
|
||||
Two cross-field rules the CLI enforces (the editor's JSON-Schema check does not — run `emdash-plugin validate`):
|
||||
|
||||
- `network:request` **requires** a non-empty `allowedHosts`. If the plugin really must reach any host, use `network:request:unrestricted` instead.
|
||||
- `network:request:unrestricted` **requires** `allowedHosts` to be empty — the unrestricted capability already grants every host, so a list would contradict it.
|
||||
|
||||
Host patterns are bare hostnames (no scheme, path, or whitespace). A leading `*.` allows subdomains: `*.cdn.example.com`.
|
||||
|
||||
### Storage
|
||||
|
||||
A map of collection name → index config. Collection names follow the same `/^[a-z][a-z0-9_]*$/` rule (the runtime uses the name as a SQL table suffix). Indexes are field names or composite arrays; `uniqueIndexes` are queryable too — don't also list them in `indexes`.
|
||||
|
||||
```jsonc
|
||||
"storage": {
|
||||
"events": { "indexes": ["timestamp", ["collection", "timestamp"]] }
|
||||
}
|
||||
```
|
||||
|
||||
## Admin surface
|
||||
|
||||
Optional. Sandboxed plugins render admin pages and dashboard widgets through [Block Kit](/plugins/creating-plugins/block-kit/); the manifest only declares where they appear. Omit the `admin` key entirely if the plugin has no admin UI.
|
||||
|
||||
```jsonc
|
||||
"admin": {
|
||||
"pages": [{ "path": "/gallery", "label": "Gallery", "icon": "image" }],
|
||||
"widgets": [{ "id": "recent-uploads", "title": "Recent uploads", "size": "half" }]
|
||||
}
|
||||
```
|
||||
|
||||
A plugin that declares `admin.pages` or `admin.widgets` must also serve an `admin` route in `src/plugin.ts` that renders the Block Kit content — the schema can't enforce that (route names are probed from source, not the manifest), but the runtime checks it.
|
||||
|
||||
## Publisher pinning
|
||||
|
||||
`publisher` pins the publishing identity so you can't accidentally publish a plugin under the wrong account.
|
||||
|
||||
On your **first successful publish**, if the manifest's `publisher` matches the active session, it stays as written. If you scaffolded with `emdash-plugin init` and left it blank, the CLI writes the active session's DID back into the manifest.
|
||||
|
||||
The following example shows the line the CLI writes, with the resolved handle added as a comment for readability:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
"publisher": "did:plc:abc123def456", // jane.example.com
|
||||
```
|
||||
|
||||
On **every subsequent publish**, the CLI resolves the active session and the pinned `publisher` to DIDs and compares them. A mismatch fails immediately with `MANIFEST_PUBLISHER_MISMATCH` — there is no override flag. Resolve it deliberately:
|
||||
|
||||
- Wrong session: `emdash-plugin switch <did>`, then publish again.
|
||||
- Genuinely transferring the plugin to a new publisher: edit `publisher` in the manifest.
|
||||
|
||||
<Aside type="note" title="Prefer a DID over a handle">
|
||||
You can pin a handle (`"publisher": "example.com"`) instead of a DID. The
|
||||
CLI resolves it to a DID before comparing, so it works as a friendlier
|
||||
alias. Handles can change owners, though: if the handle later points
|
||||
somewhere else, publishing refuses. Pin a `did:plc:...` for long-lived
|
||||
plugins.
|
||||
</Aside>
|
||||
|
||||
## Validate without publishing
|
||||
|
||||
```sh
|
||||
emdash-plugin validate # ./emdash-plugin.jsonc
|
||||
emdash-plugin validate path/ # a specific directory
|
||||
```
|
||||
|
||||
Offline schema check with `tsc`-style `file:line:column` diagnostics, including the cross-field rules. Suitable for a pre-commit hook or CI step. Duplicate keys and unknown keys are errors (strict mode catches `"licens"` typos).
|
||||
|
||||
## CLI flags still win
|
||||
|
||||
Explicit flags (`--license`, `--author-name`, …) override manifest values when both are set — useful for CI overrides. `--no-manifest` skips the manifest entirely (and warns if one exists at the default path, so the publisher-pin safety story stays visible).
|
||||
|
||||
## Next
|
||||
|
||||
- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/)
|
||||
- [Bundling and publishing](/plugins/creating-plugins/publishing/)
|
||||
- [Capabilities and security](/plugins/creating-plugins/capabilities/)
|
||||
@@ -0,0 +1,208 @@
|
||||
---
|
||||
title: Migrating to the plugin CLI
|
||||
description: Breaking changes for sandboxed-plugin authors, and how to update your plugin.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
This guide is for authors of **sandboxed** plugins written against the previous `definePlugin()` shape. Work through the breaking changes in order. None of them change how your hooks or routes behave at runtime; they change how the plugin is declared, built, and published.
|
||||
|
||||
<Aside type="note" title="Native plugins are unaffected">
|
||||
Native plugins continue to use `definePlugin()` from `emdash` and the
|
||||
`PluginDefinition` shape. Nothing on this page applies to them.
|
||||
</Aside>
|
||||
|
||||
For the full list of changes in each package, see the [EmDash changelog](https://github.com/emdash-cms/emdash/blob/main/CHANGELOG.md).
|
||||
|
||||
## Breaking changes
|
||||
|
||||
### Renamed: `@emdash-cms/registry-cli` is now `@emdash-cms/plugin-cli`
|
||||
|
||||
Earlier releases shipped the CLI as `@emdash-cms/registry-cli`, with an `emdash-registry` binary.
|
||||
|
||||
The package is now `@emdash-cms/plugin-cli` and the binary is `emdash-plugin`. The old package is no longer published.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Replace the dependency:
|
||||
|
||||
```sh
|
||||
pnpm remove @emdash-cms/registry-cli
|
||||
pnpm add -D @emdash-cms/plugin-cli
|
||||
```
|
||||
|
||||
Replace `emdash-registry` with `emdash-plugin` everywhere you call it. Every subcommand keeps its name (`bundle`, `publish`, `login`, `whoami`, `switch`, `validate`), and `init`, `build`, and `dev` are added. See [The plugin CLI](/plugins/creating-plugins/cli/).
|
||||
|
||||
### Changed: sandboxed plugins are defined with `satisfies SandboxedPlugin`
|
||||
|
||||
Earlier releases wrapped the plugin's hooks and routes in `definePlugin()` imported from `emdash`, with each handler's parameters annotated by hand.
|
||||
|
||||
A sandboxed plugin is now a bare default export annotated with `satisfies SandboxedPlugin`. The type comes from `emdash/plugin`, a type-only entry point that the bundler erases. TypeScript infers each handler's `event` and `ctx` from the hook or route name, so handler parameters need no annotations.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Make four changes to the plugin's source file. Replace the import:
|
||||
|
||||
```ts del={1} ins={2}
|
||||
import { definePlugin, type ContentHookEvent, type PluginContext } from "emdash";
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
```
|
||||
|
||||
Replace the `definePlugin()` wrapper with a bare object and a `satisfies` annotation:
|
||||
|
||||
```ts del={1} ins={2} "} satisfies SandboxedPlugin;"
|
||||
export default definePlugin({ /* hooks, routes */ });
|
||||
export default { /* hooks, routes */ } satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
Remove the parameter annotations from every handler:
|
||||
|
||||
```ts del={1} ins={2}
|
||||
handler: async (event: ContentHookEvent, ctx: PluginContext) => {
|
||||
handler: async (event, ctx) => {
|
||||
```
|
||||
|
||||
The result is one default-exported object:
|
||||
|
||||
```ts title="src/plugin.ts"
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
export default {
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
handler: async (event, ctx) => {
|
||||
return event.content;
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
To name an event type in a helper function, import it from `emdash/plugin`:
|
||||
|
||||
```ts
|
||||
import type { ContentHookEvent, PluginContext } from "emdash/plugin";
|
||||
```
|
||||
|
||||
A handler's `event` is always the canonical type for that hook. Annotating a handler with a narrower interface no longer type-checks. Validate any fields you depend on at runtime with a `typeof` check or a guard, which is the correct approach for data that comes from outside the type system.
|
||||
|
||||
### Changed: a plugin is one `src/plugin.ts` plus `emdash-plugin.jsonc`
|
||||
|
||||
Earlier releases split a plugin into two files: `src/index.ts` returned a `PluginDescriptor` (id, version, capabilities, storage, entrypoint), and `src/sandbox-entry.ts` held the hooks and routes.
|
||||
|
||||
A plugin is now one runtime file, `src/plugin.ts` (hooks and routes), and one hand-edited manifest, `emdash-plugin.jsonc` (identity and the trust contract). The `entrypoint` and `format` fields are gone; the build wires them up.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Move the hooks and routes into `src/plugin.ts` using the shape above. Move the descriptor's metadata into `emdash-plugin.jsonc` next to `package.json`. The descriptor `id` becomes the manifest `slug`; `capabilities`, `allowedHosts`, and `storage` keep their shape; `version` is read from `package.json`, so omit it.
|
||||
|
||||
The following example shows the manifest equivalent of a descriptor that declared one storage collection:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
||||
|
||||
"slug": "plugin-hello",
|
||||
"publisher": "did:plc:abc123def456",
|
||||
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe", "url": "https://example.com" },
|
||||
"security": { "email": "security@example.com" },
|
||||
|
||||
"capabilities": [],
|
||||
"allowedHosts": [],
|
||||
"storage": { "events": { "indexes": ["timestamp"] } }
|
||||
}
|
||||
```
|
||||
|
||||
See [The plugin manifest](/plugins/creating-plugins/manifest/) for every field, and [Publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning) for the `publisher` field.
|
||||
|
||||
In `package.json`, point the `"./sandbox"` export at the built runtime file:
|
||||
|
||||
```json del={1} ins={2}
|
||||
"./sandbox": "./dist/sandbox-entry.mjs"
|
||||
"./sandbox": "./dist/plugin.mjs"
|
||||
```
|
||||
|
||||
Add the manifest to `files` so it ships with the package:
|
||||
|
||||
```json del={1} ins={2}
|
||||
"files": ["dist"]
|
||||
"files": ["dist", "emdash-plugin.jsonc"]
|
||||
```
|
||||
|
||||
### Changed: build with `emdash-plugin build`
|
||||
|
||||
Earlier releases built the two source files with a hand-written `tsdown` script.
|
||||
|
||||
`emdash-plugin build` reads `emdash-plugin.jsonc` and `src/plugin.ts` and emits the `dist/` artifacts. `emdash-plugin dev` watches and rebuilds.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Replace the build script and add a watch script:
|
||||
|
||||
```json title="package.json" del={2} ins={3,4}
|
||||
"scripts": {
|
||||
"build": "tsdown src/index.ts src/sandbox-entry.ts --format esm --dts --clean"
|
||||
"build": "emdash-plugin build",
|
||||
"dev": "emdash-plugin dev"
|
||||
}
|
||||
```
|
||||
|
||||
Then validate and build:
|
||||
|
||||
```sh
|
||||
emdash-plugin validate
|
||||
emdash-plugin build
|
||||
```
|
||||
|
||||
### Removed: standard-format type and function exports from `emdash`
|
||||
|
||||
Earlier releases exported `StandardPluginDefinition`, `StandardHookHandler`, `StandardHookEntry`, `StandardRouteHandler`, `StandardRouteEntry`, and the function `isStandardPluginDefinition` from `emdash`.
|
||||
|
||||
These are removed. They were helper aliases for the previous `definePlugin` shape.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Use `SandboxedPlugin` from `emdash/plugin` for the same purpose. A sandboxed plugin's default export is already typed by its `satisfies SandboxedPlugin` annotation, so there is no replacement for `isStandardPluginDefinition`; identify a plugin by its structure (`{ hooks?, routes? }`) if you need to.
|
||||
|
||||
### Renamed: the runtime `SandboxedPlugin` type is now `SandboxedPluginInstance`
|
||||
|
||||
This affects only authors of a custom `SandboxRunner`, such as `@emdash-cms/cloudflare`. Most plugin authors can skip it.
|
||||
|
||||
`SandboxedPlugin` from `emdash` now refers to the author-facing source shape. The runtime handle returned by `SandboxRunner.load` is `SandboxedPluginInstance`.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
If you import `SandboxedPlugin` from `emdash` to type a sandbox runner or hold runtime plugin handles, change the import to `SandboxedPluginInstance`:
|
||||
|
||||
```ts del={1} ins={2}
|
||||
import type { SandboxedPlugin } from "emdash";
|
||||
import type { SandboxedPluginInstance } from "emdash";
|
||||
```
|
||||
|
||||
## Tell your users
|
||||
|
||||
Sites that install your plugin also need to change their import. Point them at the new shape: drop the braces and the `()`.
|
||||
|
||||
```js title="astro.config.mjs" del={1,7} ins={2,8}
|
||||
import { helloPlugin } from "@my-org/plugin-hello";
|
||||
import hello from "@my-org/plugin-hello";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
sandboxed: [helloPlugin()],
|
||||
sandboxed: [hello],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
If your plugin accepted configuration through its factory, that configuration moves to the admin UI's plugin settings. Read it at runtime through `ctx.kv` or settings instead. See [Settings](/plugins/creating-plugins/settings/).
|
||||
|
||||
## Next
|
||||
|
||||
- [The plugin manifest](/plugins/creating-plugins/manifest/)
|
||||
- [The plugin CLI](/plugins/creating-plugins/cli/)
|
||||
- [Your first sandboxed plugin](/plugins/creating-plugins/your-first-plugin/)
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Bundling and publishing
|
||||
description: Bundle a sandboxed plugin and publish it to the EmDash plugin registry.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
Once your sandboxed plugin works, publish it so other sites can install it. Publishing is sandboxed-only — native plugins distribute via npm.
|
||||
|
||||
When you publish, the CLI records the release to your own [Atmosphere account](#your-atmosphere-account). **You host the tarball** yourself — a GitHub release asset, R2, S3, or any public URL — and the registry stores a link to it.
|
||||
|
||||
<Aside type="caution" title="Experimental">
|
||||
The registry, its lexicon, and record shapes are governed by RFC 0001 and
|
||||
will change while it's in flight. Pin `@emdash-cms/plugin-cli` to an exact
|
||||
version. The discovery side (`search`, `info`) queries an experimental
|
||||
registry at `registry.emdashcms.com`.
|
||||
</Aside>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A valid [`emdash-plugin.jsonc`](/plugins/creating-plugins/manifest/) with `slug`, `publisher`, `license`, an author (`author` or `authors`), and a security contact (`security` or `securityContacts`). Run `emdash-plugin validate` to confirm.
|
||||
- A `version` (in `package.json`, or the manifest for registry-only plugins).
|
||||
- An [Atmosphere account](#your-atmosphere-account) to publish under.
|
||||
|
||||
## Your Atmosphere account
|
||||
|
||||
You publish under an **[Atmosphere account](https://atmosphereaccount.com)**: a portable, user-owned identity used across [Bluesky](https://bsky.app) and other apps in the [AT Protocol](https://atproto.com/) network. One account is your single login across the network, with the same `@handle` everywhere, and your identity and data are not tied to any one app. EmDash uses this account as your publisher identity: every release you publish is a record in your own account, signed in as you.
|
||||
|
||||
EmDash uses the same Atmosphere accounts as its [Atmosphere login](/guides/atmosphere-auth/) for sites.
|
||||
|
||||
### Use an existing account
|
||||
|
||||
If you already have a Bluesky account or any other Atmosphere account, sign in with its handle:
|
||||
|
||||
```sh
|
||||
emdash-plugin login alice.bsky.social
|
||||
```
|
||||
|
||||
This opens your account provider's sign-in page in the browser. EmDash never sees your password. `emdash-plugin whoami` lists your stored sessions; `emdash-plugin switch <did>` changes the active one.
|
||||
|
||||
### Sign up for an account
|
||||
|
||||
If you do not have an Atmosphere account yet, create one through any provider, then run `emdash-plugin login <your-handle>`. Your options:
|
||||
|
||||
- **An app, such as [Bluesky](https://bsky.app).** Signing up for Bluesky creates an Atmosphere account hosted by Bluesky. This is the quickest route.
|
||||
- **An independent provider.** Community-run or privacy-focused account hosts. Browse options at [atmosphereaccount.com](https://atmosphereaccount.com).
|
||||
- **Self-hosted.** Run your own provider for full control over your identity and data.
|
||||
|
||||
Whichever you choose, the `@handle` from that account is what you pass to `emdash-plugin login`, and the account's DID is what you pin as the [`publisher`](/plugins/creating-plugins/manifest/#publisher-pinning) in your manifest.
|
||||
|
||||
## Three steps
|
||||
|
||||
The following commands log in, build a tarball, and publish a release that points at the hosted tarball:
|
||||
|
||||
```sh
|
||||
emdash-plugin login # if not already logged in
|
||||
emdash-plugin bundle # produces dist/<slug>-<version>.tar.gz
|
||||
# upload that tarball to a public URL, then:
|
||||
emdash-plugin publish --url https://your-host/<slug>-<version>.tar.gz
|
||||
```
|
||||
|
||||
`bundle` prints the next two steps when it finishes, including the `--url` invocation, so you don't have to remember the shape.
|
||||
|
||||
## Bundle
|
||||
|
||||
`bundle` runs [`build`](/plugins/creating-plugins/cli/#build), validates, collects assets, and creates a tarball. Inside the tarball, `plugin.mjs` is packed as `backend.js` (the filename the registry expects).
|
||||
|
||||
The command accepts the following flags:
|
||||
|
||||
```sh
|
||||
emdash-plugin bundle [--dir <path>] [--out-dir|-o <path>] [--validate-only]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---------------- | ----------------- | ------------------------------------------------------ |
|
||||
| `--dir` | Current directory | Plugin source directory. |
|
||||
| `--out-dir`, `-o` | `dist` | Output directory for the tarball. |
|
||||
| `--validate-only` | `false` | Skip the tarball, but still produce `dist/` artifacts. |
|
||||
|
||||
### Tarball contents
|
||||
|
||||
| File | Required | Description |
|
||||
| --------------- | -------- | ------------------------------------------------------------ |
|
||||
| `manifest.json` | Yes | Generated manifest: id, version, capabilities, hosts, and the hooks and routes read from your source. You do not maintain this by hand. |
|
||||
| `backend.js` | Yes | The built, self-contained runtime file (`dist/plugin.mjs`). |
|
||||
| `README.md` | No | Plugin documentation. |
|
||||
| `icon.png` | No | 256×256 PNG. |
|
||||
| `screenshots/` | No | Up to 5, max 1920×1080. |
|
||||
|
||||
### Validation
|
||||
|
||||
`bundle` (and `--validate-only`) check:
|
||||
|
||||
- **Size caps (RFC 0001, decompressed):** total ≤ 256 KB, per-file ≤ 128 KB, ≤ 20 files. The gzipped tarball is a fraction of that.
|
||||
- **No Node built-ins in `backend.js`** — sandbox code can't import `fs`, `path`, `child_process`, etc. Use Web APIs, or move that logic to a native plugin.
|
||||
- **Capability sanity** — names must be in the recognised set.
|
||||
- **Trust-contract coherence** — the `network:request` / `allowedHosts` cross-rules from [the manifest](/plugins/creating-plugins/manifest/#capabilities).
|
||||
- **Asset limits** — icon 256×256, ≤ 5 screenshots at ≤ 1920×1080.
|
||||
|
||||
To inspect the tarball before publishing, list its contents:
|
||||
|
||||
```sh
|
||||
emdash-plugin bundle
|
||||
tar tzf dist/my-plugin-1.1.0.tar.gz
|
||||
```
|
||||
|
||||
## Publish
|
||||
|
||||
Publishing writes the release record. The tarball must already be hosted at a public URL:
|
||||
|
||||
```sh
|
||||
emdash-plugin publish --url <hosted-tarball-url>
|
||||
```
|
||||
|
||||
`--url` is required: it is where the plugin's bytes live, and the registry record points at it. To verify the hosted URL serves the exact bytes you built before writing the record, pass `--local`:
|
||||
|
||||
```sh
|
||||
emdash-plugin publish --url https://your-host/foo-1.0.0.tar.gz --local dist/foo-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
What `publish` does:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Fetches the tarball at `--url` (with URL and size guards) and extracts the manifest from those bytes.
|
||||
2. Resumes your Atmosphere account session and checks [publisher pinning](/plugins/creating-plugins/manifest/#publisher-pinning) — the active session must match the manifest's pinned `publisher`, or it refuses with `MANIFEST_PUBLISHER_MISMATCH`.
|
||||
3. Creates the package profile from the manifest on first publish (`license`, author, security contact). On later publishes, the existing profile wins.
|
||||
4. Publishes the profile and release records to your account.
|
||||
|
||||
</Steps>
|
||||
|
||||
On first publish you can supply profile fields by flag (`--license`, `--security-email`, …) instead of the manifest; explicit flags override manifest values, which is handy in CI. `--no-manifest` opts out of the manifest entirely.
|
||||
|
||||
### Versions are immutable
|
||||
|
||||
A published version cannot be overwritten or republished. Bump `version` before publishing again. The build reads `version` from `package.json` (see [the manifest reference](/plugins/creating-plugins/manifest/#version-lives-in-packagejson)). Bump **major** for a broadened [trust contract](/plugins/creating-plugins/manifest/#trust-contract), **minor** for new hooks or routes, and **patch** for fixes.
|
||||
|
||||
### Publisher mismatch
|
||||
|
||||
If `publish` fails with `MANIFEST_PUBLISHER_MISMATCH`, the active session is a different Atmosphere account than the manifest's pinned `publisher`. Switch to the pinned account with `emdash-plugin switch <did>`, or update `publisher` in the manifest if you are genuinely transferring the plugin to a new account. See [Use an existing account](#use-an-existing-account) for managing sessions.
|
||||
|
||||
## What to read next
|
||||
|
||||
- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/) — every command
|
||||
- [The manifest](/plugins/creating-plugins/manifest/) — fields, trust contract, publisher pinning
|
||||
- [Capabilities and security](/plugins/creating-plugins/capabilities/)
|
||||
@@ -0,0 +1,230 @@
|
||||
---
|
||||
title: Settings
|
||||
description: Per-plugin configuration through the KV store, exposed in the admin UI as a Block Kit page.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
Sandboxed plugins store their settings in the per-plugin **KV store** and render the editing UI as a [Block Kit](/plugins/creating-plugins/block-kit/) page: you describe the form in JSON and serve it from a route.
|
||||
|
||||
Everything happens through the same machinery the plugin already uses for hooks and routes — there's nothing extra to learn.
|
||||
|
||||
## The KV store
|
||||
|
||||
Every plugin gets a private key-value store accessible as `ctx.kv` in any hook or route. It's the canonical place for settings and any other small persistent state:
|
||||
|
||||
```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 }>>;
|
||||
}
|
||||
```
|
||||
|
||||
KV is per-plugin — keys you write are stored under your plugin id and aren't visible to other plugins.
|
||||
|
||||
### Reading and writing
|
||||
|
||||
```typescript
|
||||
// Read
|
||||
const enabled = await ctx.kv.get<boolean>("settings:enabled");
|
||||
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");
|
||||
|
||||
// Write
|
||||
await ctx.kv.set("settings:lastSync", new Date().toISOString());
|
||||
await ctx.kv.set("state:cache", { data: items, expiry: Date.now() + 3600000 });
|
||||
|
||||
// Delete
|
||||
const deleted = await ctx.kv.delete("state:tempData");
|
||||
|
||||
// List by prefix
|
||||
const allSettings = await ctx.kv.list("settings:");
|
||||
// → [{ key: "settings:enabled", value: true }, ...]
|
||||
```
|
||||
|
||||
### Key naming conventions
|
||||
|
||||
Use prefixes to keep different kinds of values separate. The convention across EmDash plugins:
|
||||
|
||||
| Prefix | Purpose | Example |
|
||||
| ----------- | ----------------------------- | ----------------- |
|
||||
| `settings:` | User-configurable preferences | `settings:apiKey` |
|
||||
| `state:` | Internal plugin state | `state:lastSync` |
|
||||
| `cache:` | Cached data | `cache:results` |
|
||||
|
||||
```typescript
|
||||
// Clear prefixes
|
||||
await ctx.kv.set("settings:webhookUrl", url);
|
||||
await ctx.kv.set("state:lastRun", timestamp);
|
||||
await ctx.kv.set("cache:feed", feedData);
|
||||
|
||||
// Avoid bare keys
|
||||
await ctx.kv.set("url", url);
|
||||
```
|
||||
|
||||
## Settings UI in Block Kit
|
||||
|
||||
Sandboxed plugins describe their settings page as Block Kit. The admin sends a `page_load` interaction to a route on your plugin (conventionally `routes.admin`), and the plugin returns a JSON description of the form. When the user clicks Save, the admin sends a `block_action` or `form_submit` interaction back; the plugin writes to KV and returns updated blocks.
|
||||
|
||||
```typescript title="src/plugin.ts"
|
||||
import type { PluginContext, SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
interface BlockInteraction {
|
||||
type: "page_load" | "block_action" | "form_submit";
|
||||
page?: string;
|
||||
action_id?: string;
|
||||
values?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export default {
|
||||
routes: {
|
||||
admin: {
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const interaction = routeCtx.input as BlockInteraction;
|
||||
|
||||
if (interaction.type === "page_load" && interaction.page === "/settings") {
|
||||
return renderSettings(ctx);
|
||||
}
|
||||
|
||||
if (interaction.type === "form_submit" && interaction.action_id === "save") {
|
||||
await saveSettings(ctx, interaction.values ?? {});
|
||||
return {
|
||||
...(await renderSettings(ctx)),
|
||||
toast: { message: "Settings saved", type: "success" },
|
||||
};
|
||||
}
|
||||
|
||||
return { blocks: [] };
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
|
||||
async function renderSettings(ctx: PluginContext) {
|
||||
const apiKey = (await ctx.kv.get<string>("settings:apiKey")) ?? "";
|
||||
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
|
||||
const maxItems = (await ctx.kv.get<number>("settings:maxItems")) ?? 100;
|
||||
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "header", text: "Plugin settings" },
|
||||
{
|
||||
type: "form",
|
||||
block_id: "settings",
|
||||
fields: [
|
||||
{
|
||||
type: "secret_input",
|
||||
action_id: "apiKey",
|
||||
label: "API key",
|
||||
initial_value: apiKey,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "enabled",
|
||||
label: "Enabled",
|
||||
initial_value: enabled,
|
||||
},
|
||||
{
|
||||
type: "number_input",
|
||||
action_id: "maxItems",
|
||||
label: "Max items",
|
||||
min: 1,
|
||||
max: 1000,
|
||||
initial_value: maxItems,
|
||||
},
|
||||
],
|
||||
submit: { label: "Save", action_id: "save" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value !== undefined) {
|
||||
await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
To wire the settings page into the admin sidebar, declare it in the manifest:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
"admin": {
|
||||
"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }]
|
||||
}
|
||||
```
|
||||
|
||||
EmDash routes `page_load` interactions for that path to your `admin` route automatically.
|
||||
|
||||
See [Block Kit](/plugins/creating-plugins/block-kit/) for the full set of block types, form fields, conditional fields, and the `@emdash-cms/blocks` builder helpers.
|
||||
|
||||
## Secret values
|
||||
|
||||
Block Kit's `secret_input` field renders as a masked input. Treat any value the user enters there with care:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "secret_input",
|
||||
action_id: "apiKey",
|
||||
label: "API key",
|
||||
// Don't seed initial_value with the real secret — pass an empty string or a sentinel,
|
||||
// and only overwrite when the user enters a non-empty value.
|
||||
initial_value: "",
|
||||
}
|
||||
```
|
||||
|
||||
When saving, skip empty strings to avoid clearing the existing secret on every save:
|
||||
|
||||
```typescript
|
||||
async function saveSettings(ctx: PluginContext, values: Record<string, unknown>) {
|
||||
if (typeof values.apiKey === "string" && values.apiKey.length > 0) {
|
||||
await ctx.kv.set("settings:apiKey", values.apiKey);
|
||||
}
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
## Default values
|
||||
|
||||
KV reads return `null` for keys that haven't been written. Pass defaults at the read site:
|
||||
|
||||
```typescript
|
||||
const enabled = (await ctx.kv.get<boolean>("settings:enabled")) ?? true;
|
||||
const maxItems = (await ctx.kv.get<number>("settings:maxItems")) ?? 100;
|
||||
```
|
||||
|
||||
Or persist defaults during installation:
|
||||
|
||||
```typescript
|
||||
hooks: {
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.kv.set("settings:maxItems", 100);
|
||||
},
|
||||
},
|
||||
```
|
||||
|
||||
The trade-off is that `plugin:install` runs once per install. If you ship a new setting in a later version, only fresh installs see the default — existing installs need either a migration in `plugin:activate` (idempotent: only write if missing) or to keep using the read-time fallback.
|
||||
|
||||
## Settings vs storage vs KV
|
||||
|
||||
| Use case | Mechanism |
|
||||
| ------------------------------ | -------------------------------------------------- |
|
||||
| Admin-editable preferences | `ctx.kv` with `settings:` prefix + Block Kit page |
|
||||
| Internal plugin state | `ctx.kv` with `state:` prefix |
|
||||
| Document collections (queries) | `ctx.storage` |
|
||||
|
||||
**KV** is for small values keyed by a string — settings, sync cursors, cached computations. No queries, no indexes.
|
||||
|
||||
**Storage** is for document collections with indexed queries — form submissions, audit logs, anything where you want to filter, paginate, or count.
|
||||
|
||||
## Storage layout
|
||||
|
||||
KV values live in the `_options` table with plugin-namespaced keys. Your code uses `settings:apiKey`; EmDash stores it as `plugin:<your-plugin-id>:settings:apiKey`. The prefix is added automatically and prevents one plugin from reading or overwriting another's KV data.
|
||||
|
||||
## Native plugins: `settingsSchema`
|
||||
|
||||
If you're writing a native plugin (because you need React admin pages or PT components), you can declare a settings schema directly inside `definePlugin()` and let EmDash auto-generate the form. See [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for that path.
|
||||
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: Storage
|
||||
description: Persist plugin data in document collections with indexed queries.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Sandboxed plugins can store their own data in document collections. You declare collections and indexes in the [manifest](/plugins/creating-plugins/manifest/), and EmDash creates the schema automatically — no migrations to write.
|
||||
|
||||
This page covers sandboxed plugins. The collection API is identical for native plugins; the only difference is that native plugins declare `storage` inside `definePlugin()` rather than in the manifest.
|
||||
|
||||
## Declaring storage in the manifest
|
||||
|
||||
For sandboxed plugins, `storage` lives in `emdash-plugin.jsonc`. The declaration has to be visible at build time so the sandbox bridge knows which collections the plugin is allowed to touch.
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"slug": "forms",
|
||||
// ...identity + profile...
|
||||
|
||||
"storage": {
|
||||
"submissions": {
|
||||
"indexes": [
|
||||
"formId",
|
||||
"status",
|
||||
"createdAt",
|
||||
["formId", "createdAt"],
|
||||
["status", "createdAt"]
|
||||
]
|
||||
},
|
||||
"forms": {
|
||||
"indexes": ["slug"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently — single-field indexes as strings, composite indexes as arrays of strings. See [the manifest reference](/plugins/creating-plugins/manifest/#storage) for the full rules.
|
||||
|
||||
<Aside type="note">
|
||||
Storage is scoped to the plugin slug. A `submissions` collection in the `forms` plugin is completely separate from `submissions` in another plugin — the plugin can only see its own data.
|
||||
</Aside>
|
||||
|
||||
## Using storage at runtime
|
||||
|
||||
In `src/plugin.ts`, access collections via `ctx.storage`. The shape mirrors what was declared in the manifest:
|
||||
|
||||
```typescript title="src/plugin.ts"
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
export default {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async (event, ctx) => {
|
||||
const { submissions } = ctx.storage;
|
||||
|
||||
await submissions.put("sub_123", {
|
||||
formId: "contact",
|
||||
email: "user@example.com",
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const item = await submissions.get("sub_123");
|
||||
ctx.log.info("Stored submission", { id: item?.formId });
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
Accessing a collection that wasn't declared in the manifest throws — the bridge enforces this at the runtime level.
|
||||
|
||||
## Collection API
|
||||
|
||||
```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
|
||||
|
||||
`query()` returns paginated results filtered by indexed fields:
|
||||
|
||||
```typescript
|
||||
const result = await ctx.storage.submissions.query({
|
||||
where: {
|
||||
formId: "contact",
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// result.items — Array<{ id, data }>
|
||||
// result.cursor — pagination cursor (if more results exist)
|
||||
// result.hasMore — boolean
|
||||
```
|
||||
|
||||
### 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" },
|
||||
score: { gt: 50, lte: 100 },
|
||||
}
|
||||
// Available: gt, gte, lt, lte
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="In list">
|
||||
```typescript
|
||||
where: {
|
||||
status: { in: ["pending", "approved"] },
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Starts with">
|
||||
```typescript
|
||||
where: {
|
||||
slug: { startsWith: "blog-" },
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Ordering
|
||||
|
||||
```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 a validation error — this prevents accidental full-table scans.
|
||||
</Aside>
|
||||
|
||||
## Pagination
|
||||
|
||||
Drain a cursor to walk all matching items:
|
||||
|
||||
```typescript
|
||||
async function getAllSubmissions(ctx: PluginContext) {
|
||||
const all: Array<{ id: string; data: unknown }> = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await ctx.storage.submissions.query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
all.push(...result.items);
|
||||
cursor = result.cursor;
|
||||
} while (cursor);
|
||||
|
||||
return all;
|
||||
}
|
||||
```
|
||||
|
||||
## Counting
|
||||
|
||||
```typescript
|
||||
const total = await ctx.storage.submissions.count();
|
||||
|
||||
const pending = await ctx.storage.submissions.count({
|
||||
status: "pending",
|
||||
});
|
||||
```
|
||||
|
||||
## Batch operations
|
||||
|
||||
```typescript
|
||||
const items = await ctx.storage.submissions.getMany(["sub_1", "sub_2", "sub_3"]);
|
||||
// Returns Map<string, T>
|
||||
|
||||
await ctx.storage.submissions.putMany([
|
||||
{ id: "sub_1", data: { formId: "contact", status: "new" } },
|
||||
{ id: "sub_2", data: { formId: "contact", status: "new" } },
|
||||
]);
|
||||
|
||||
const deletedCount = await ctx.storage.submissions.deleteMany(["sub_1", "sub_2"]);
|
||||
```
|
||||
|
||||
## Index design
|
||||
|
||||
Choose indexes based on actual 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"]:
|
||||
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } }); // uses index
|
||||
query({ where: { formId: "contact" } }); // uses index (filter only)
|
||||
query({ where: { createdAt: { gte: "2024-01-01" } } }); // does NOT use this composite — filter starts at the wrong field
|
||||
```
|
||||
|
||||
## Type safety
|
||||
|
||||
Cast collection access for IntelliSense on item shapes:
|
||||
|
||||
```typescript
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
import type { StorageCollection } from "emdash";
|
||||
|
||||
interface Submission {
|
||||
formId: string;
|
||||
email: string;
|
||||
data: Record<string, unknown>;
|
||||
status: "pending" | "approved" | "spam";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async (event, ctx) => {
|
||||
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
|
||||
|
||||
await submissions.put(`sub_${Date.now()}`, {
|
||||
formId: "contact",
|
||||
email: "user@example.com",
|
||||
data: { message: "Hello" },
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
Both imports are type-only, so a sandboxed plugin has no runtime dependency on `emdash`.
|
||||
|
||||
## Storage vs content vs KV
|
||||
|
||||
Pick the right mechanism for each kind of data:
|
||||
|
||||
| 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 the admin UI | Site collections (not plugin storage) |
|
||||
|
||||
If site editors need to view or edit the data in the admin UI through the regular content editor, create a site collection instead.
|
||||
|
||||
## Implementation details
|
||||
|
||||
Plugin storage uses a single namespaced 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 gives you no migrations, portability across SQLite/libSQL/D1, plugin-level isolation, and parameterised queries on every path.
|
||||
|
||||
## Adding indexes
|
||||
|
||||
When you add an index in a plugin update, EmDash creates it automatically on next startup. Adding an index is safe and requires no data migration. When you remove an index, EmDash drops it — and queries on that field start failing with a validation error, which is the intended signal.
|
||||
@@ -0,0 +1,229 @@
|
||||
---
|
||||
title: Your first sandboxed plugin
|
||||
description: Build, register, and run a hello-world sandboxed plugin.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
This guide builds a minimal sandboxed plugin from scratch. The plugin logs every content save and exposes a single API route. It runs in an isolated runtime provided by the configured sandbox runner. The same code also runs in-process when a site operator moves it from `sandboxed: []` into `plugins: []`, for example on a platform without a sandbox runner.
|
||||
|
||||
If you haven't decided between sandboxed and native, read [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) first.
|
||||
|
||||
<Aside type="tip" title="The fast path">
|
||||
`npx @emdash-cms/plugin-cli init my-plugin` scaffolds everything below —
|
||||
manifest, `src/plugin.ts`, `package.json`, `tsconfig.json`, a test. This
|
||||
guide explains what it generates so you can write or edit it by hand.
|
||||
</Aside>
|
||||
|
||||
## Two pieces
|
||||
|
||||
A sandboxed plugin is:
|
||||
|
||||
1. **`emdash-plugin.jsonc`** — a hand-edited [manifest](/plugins/creating-plugins/manifest/): identity, the trust contract (capabilities, hosts, storage), and profile fields. No code.
|
||||
2. **`src/plugin.ts`** — the runtime: hooks and routes. Type-only imports from `emdash/plugin`; no runtime `emdash` import.
|
||||
|
||||
`emdash-plugin build` reads both and emits the `dist/` artifacts a site consumes.
|
||||
|
||||
The following example shows the file layout of a complete plugin:
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
├── emdash-plugin.jsonc # Identity + trust contract + profile
|
||||
├── src/
|
||||
│ └── plugin.ts # Hooks, routes — runs in the sandbox runtime
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Set up the package
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Create the directory and a `package.json`. The build is `emdash-plugin build`; there is no `tsdown` invocation to write.
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "@my-org/plugin-hello",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "dist/index.mjs",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.mjs",
|
||||
"types": "./dist/index.d.mts"
|
||||
},
|
||||
"./sandbox": "./dist/plugin.mjs"
|
||||
},
|
||||
"files": ["dist", "emdash-plugin.jsonc"],
|
||||
"scripts": {
|
||||
"build": "emdash-plugin build",
|
||||
"dev": "emdash-plugin dev"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"emdash": ">=0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@emdash-cms/plugin-cli": "0.2.0",
|
||||
"emdash": ">=0.13.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`"."` is the generated descriptor a site imports; `"./sandbox"` is the built runtime file. `emdash-plugin build` generates both.
|
||||
|
||||
2. Add a `tsconfig.json`:
|
||||
|
||||
```json title="tsconfig.json"
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Write the manifest
|
||||
|
||||
`emdash-plugin.jsonc` carries the plugin's identity (`slug`), its trust contract (`capabilities`, `allowedHosts`, `storage`), profile fields, and the [publisher pin](/plugins/creating-plugins/manifest/#publisher-pinning).
|
||||
|
||||
The following example shows a complete manifest for the hello plugin:
|
||||
|
||||
```jsonc title="emdash-plugin.jsonc"
|
||||
{
|
||||
"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
||||
|
||||
"slug": "plugin-hello",
|
||||
"publisher": "did:plc:abc123def456", // your Atmosphere account DID
|
||||
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe", "url": "https://example.com" },
|
||||
"security": { "email": "security@example.com" },
|
||||
|
||||
"capabilities": [],
|
||||
"allowedHosts": [],
|
||||
"storage": { "events": { "indexes": ["timestamp"] } }
|
||||
}
|
||||
```
|
||||
|
||||
Notes on this manifest:
|
||||
|
||||
- **`slug` is a URL-safe id, not the npm package name.** `/^[a-z][a-z0-9_-]*$/`, max 64 chars. It's a single path segment in plugin route URLs (`/_emdash/api/plugins/<slug>/...`) and part of generated SQL identifiers for storage indexes, so `@`, `/`, leading digits, and uppercase all fail. Pair an unscoped `slug` (`plugin-hello`) with a scoped npm package name.
|
||||
- **`storage` declares collections up front.** `ctx.storage.events` works at runtime only because `events` is declared here. Accessing an undeclared collection throws.
|
||||
- **`version` is omitted.** The build reads it from `package.json` so there's one source of truth. See [the manifest reference](/plugins/creating-plugins/manifest/#version-lives-in-packagejson).
|
||||
- **The trust contract is consent.** Changing `capabilities`, `allowedHosts`, or `storage` later requires a version bump — installed sites consented to the old contract.
|
||||
|
||||
## Write the runtime
|
||||
|
||||
`src/plugin.ts` default-exports a bare object annotated with `satisfies SandboxedPlugin`. `emdash/plugin` provides only types, so a sandboxed plugin has no runtime dependency on `emdash`.
|
||||
|
||||
The following example logs every content save to plugin storage and exposes a `recent` route that returns the last ten saves:
|
||||
|
||||
```typescript title="src/plugin.ts"
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
export default {
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
handler: async (event, ctx) => {
|
||||
ctx.log.info("Content saved", {
|
||||
collection: event.collection,
|
||||
id: event.content.id,
|
||||
});
|
||||
|
||||
await ctx.storage.events.put(`save-${Date.now()}`, {
|
||||
timestamp: new Date().toISOString(),
|
||||
collection: event.collection,
|
||||
contentId: event.content.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
recent: {
|
||||
handler: async (_routeCtx, ctx) => {
|
||||
const result = await ctx.storage.events.query({ limit: 10 });
|
||||
return { events: result.items };
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
```
|
||||
|
||||
Notes on the runtime file:
|
||||
|
||||
- **`satisfies SandboxedPlugin` types everything.** It infers `event` from the hook name (with the full canonical event type) and `ctx` as `PluginContext`, so handlers need no parameter annotations. A typo'd hook key like `"content:afterSav"` is a compile error.
|
||||
- **Hook handlers take `(event, ctx)`.** The event shape depends on the hook name; see the [Hooks guide](/plugins/creating-plugins/hooks/).
|
||||
- **Route handlers take `(routeCtx, ctx)`** — two arguments. `routeCtx` is `{ input, request, requestMeta? }`; `ctx` is the same `PluginContext`. Routes are reachable at `/_emdash/api/plugins/<slug>/<route-name>`.
|
||||
- **`ctx.storage.events` works because `events` is declared in the manifest.**
|
||||
- **`ctx.kv` is always available** — a per-plugin key-value store with `get`, `set`, `delete`, `list(prefix)`.
|
||||
|
||||
## Register the plugin
|
||||
|
||||
In the site's `astro.config.mjs`, import the plugin's default export and pass it in. Sandboxed plugins go in `sandboxed: []`; in-process plugins go in `plugins: []`. A sandboxed plugin works in both. The example below uses `sandboxed:`:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { sandbox } from "@emdash-cms/cloudflare";
|
||||
import hello from "@my-org/plugin-hello";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
sandboxed: [hello],
|
||||
sandboxRunner: sandbox(),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
`sandboxRunner` is the pluggable piece. The example uses `sandbox()` from `@emdash-cms/cloudflare`, the runner most sites use today. If no runner is configured (or the configured runner is unavailable on the current platform), `sandboxed: []` plugins are skipped at startup — move the plugin into `plugins: []` to run it in-process.
|
||||
|
||||
<Aside type="caution" title="Cloudflare runner needs Worker Loader">
|
||||
The `@emdash-cms/cloudflare` sandbox runner uses Cloudflare Workers' Dynamic
|
||||
Worker Loader, so `wrangler.jsonc` needs a `worker_loaders` binding:
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"worker_loaders": [{ "binding": "LOADER" }]
|
||||
}
|
||||
```
|
||||
|
||||
Without it the runner reports as unavailable at startup and sandboxed
|
||||
plugins are skipped. Other runners have their own platform requirements. See
|
||||
[Sandboxed vs Native](/plugins/creating-plugins/choosing-a-format/) for the
|
||||
platform matrix.
|
||||
</Aside>
|
||||
|
||||
## Build and run
|
||||
|
||||
From the plugin directory:
|
||||
|
||||
```sh
|
||||
emdash-plugin validate # schema-check the manifest first
|
||||
emdash-plugin build # emit dist/
|
||||
```
|
||||
|
||||
For an edit loop, run `emdash-plugin dev` (rebuilds on save, keeps the last good `dist/` on a failed build). In the site, install or link the plugin (`pnpm add file:../plugin-hello` or a workspace link) and start the dev server. Save a piece of content in the admin and you should see `Content saved …` in the logs; `GET /_emdash/api/plugins/plugin-hello/recent` returns the last ten save events.
|
||||
|
||||
## What to read next
|
||||
|
||||
- [The manifest](/plugins/creating-plugins/manifest/) — every field, the trust contract, publisher pinning
|
||||
- [The `emdash-plugin` CLI](/plugins/creating-plugins/cli/) — `build`, `dev`, `bundle`
|
||||
- [Hooks](/plugins/creating-plugins/hooks/) — the full set of events
|
||||
- [API routes](/plugins/creating-plugins/api-routes/) — input validation, public routes, errors
|
||||
- [Storage and KV](/plugins/creating-plugins/storage/) — query options, indexes, batch operations
|
||||
- [Capabilities and security](/plugins/creating-plugins/capabilities/) — content access, network, host allowlists
|
||||
- [Bundling and publishing](/plugins/creating-plugins/publishing/) — shipping to the marketplace
|
||||
@@ -0,0 +1,245 @@
|
||||
---
|
||||
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, edited by default through a single-line text input that takes raw JSON. **Field Kit** is a first-party plugin that ships four composable widgets for `json` fields, configured entirely through seed `options` so site builders can use them with seed schema alone.
|
||||
|
||||
<Aside type="tip">
|
||||
Field Kit widgets store **clean JSON**: the stored value is plain content data, so removing the plugin leaves valid content behind.
|
||||
</Aside>
|
||||
|
||||
## Installation
|
||||
|
||||
Install the package from npm:
|
||||
|
||||
```bash
|
||||
npm i @emdash-cms/plugin-field-kit
|
||||
```
|
||||
|
||||
The following configuration registers the plugin:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [fieldKitPlugin()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Attach a widget to any `json` field by setting `widget` to `field-kit:<name>`. The following field definition uses the `list` widget:
|
||||
|
||||
```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. The following field definition configures a nutrition object:
|
||||
|
||||
```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. The following field definition configures an ingredients list:
|
||||
|
||||
```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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The stored value is an array of row objects:
|
||||
|
||||
```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. The following field definition configures a seasonal availability grid:
|
||||
|
||||
```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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The stored value is an object keyed by row, then by column:
|
||||
|
||||
```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`. The following field definition configures a keywords tag input:
|
||||
|
||||
```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}"`. The following template combines two keys:
|
||||
|
||||
```
|
||||
"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, using only that column. If you remove `@emdash-cms/plugin-field-kit` from your config, the data stays valid — the field reverts 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.
|
||||
- [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/) — 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.
|
||||
@@ -0,0 +1,166 @@
|
||||
---
|
||||
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 always run sandboxed; config-based plugins run sandboxed or in-process depending on which array they're declared in (`sandboxed: []` vs `plugins: []`).
|
||||
|
||||
## From the Marketplace
|
||||
|
||||
The admin dashboard includes a marketplace browser where you can search, install, and manage plugins.
|
||||
|
||||
<Aside type="tip">
|
||||
EmDash also has an experimental, federated alternative to the central marketplace. See [The plugin registry](/plugins/registry/).
|
||||
</Aside>
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To install marketplace plugins, your site needs:
|
||||
|
||||
1. **Sandbox runner configured** — Marketplace plugins run in an isolated runtime, which requires the sandbox runner. The following configuration enables it:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
marketplace: "https://marketplace.emdashcms.com",
|
||||
sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
On **Cloudflare Workers**, sandboxing uses the Dynamic Worker Loader API (no additional setup needed). On **Node.js**, install the workerd sandbox runner:
|
||||
|
||||
```bash
|
||||
npm install @emdash-cms/sandbox-workerd
|
||||
```
|
||||
|
||||
Then pass the runner explicitly:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
emdash({
|
||||
marketplace: "https://marketplace.emdashcms.com",
|
||||
sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox",
|
||||
})
|
||||
```
|
||||
|
||||
In development, install `miniflare` as a dev dependency for faster sandbox startup:
|
||||
|
||||
```bash
|
||||
npm install -D miniflare
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
Native plugins — your own code, or packages installed via npm — are added directly to the Astro config. The following example registers the SEO plugin:
|
||||
|
||||
```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 from the admin panel.
|
||||
</Aside>
|
||||
|
||||
## Marketplace vs. config — when to use which
|
||||
|
||||
| | Marketplace (sandboxed) | Config (native or in-process sandboxed) |
|
||||
| --- | --- | --- |
|
||||
| **Install method** | One-click in admin UI | Code change + `npm install` + deploy |
|
||||
| **Execution** | Sandbox runtime via the configured runner | In-process (or sandboxed if listed under `sandboxed: []` and a runner is available) |
|
||||
| **Capabilities** | Enforced by the sandbox bridge — `ctx.*` gating plus runtime isolation | `ctx.*` gating only (in-process plugins can bypass via direct `fetch()`, env, imports) |
|
||||
| **Node.js APIs** | Not available | Full access (in-process only) |
|
||||
| **React admin pages** | No (Block Kit instead) | Yes (native plugins) |
|
||||
| **PT rendering components** | No | Yes (native plugins) |
|
||||
| **Updates** | One-click in admin | Version bump + deploy |
|
||||
| **Best for** | Most plugins | Plugins needing build-time integration |
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
title: Plugins
|
||||
description: Extend EmDash with hooks, storage, settings, admin pages, and API routes.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins extend EmDash through a defined extension surface. They can react to content lifecycle events, store their own data, expose settings to administrators, add pages to the admin panel, and serve API routes.
|
||||
|
||||
## What plugins can do
|
||||
|
||||
<CardGrid>
|
||||
<Card title="React to events" icon="rocket">
|
||||
Run code on content saves, media uploads, comment moderation, scheduled tasks, and plugin lifecycle events.
|
||||
</Card>
|
||||
<Card title="Store data" icon="document">
|
||||
Persist plugin-specific records in indexed collections, plus a per-plugin key-value store for settings and state.
|
||||
</Card>
|
||||
<Card title="Add admin pages" icon="laptop">
|
||||
Contribute pages and dashboard widgets to the admin panel, with auto-generated settings forms.
|
||||
</Card>
|
||||
<Card title="Serve API routes">
|
||||
Expose endpoints under `/_emdash/api/plugins/<id>/<route>` for the admin UI or external integrations.
|
||||
</Card>
|
||||
<Card title="Call external APIs">
|
||||
Make HTTP requests with declared host allowlists.
|
||||
</Card>
|
||||
<Card title="Send email">
|
||||
Send transactional email through whichever provider is configured.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Two kinds of plugins
|
||||
|
||||
EmDash plugins come in two formats:
|
||||
|
||||
- **Sandboxed plugins** run in an isolated runtime managed by a configurable sandbox runner. They can be installed from the marketplace with one click, are subject to capability and resource enforcement, and reach only the APIs they declare. This is the recommended choice for most plugins.
|
||||
- **Native plugins** run in the same process as your Astro site. They have full access to the runtime, can ship React admin pages and Portable Text rendering components, and inject HTML into public pages. They install via a code change plus a deploy, and run from npm rather than the marketplace.
|
||||
|
||||
If you're installing a plugin someone else built, you almost always want sandboxed. If you're building one yourself, see [Choosing a plugin format](/plugins/creating-plugins/choosing-a-format/).
|
||||
|
||||
## For site operators
|
||||
|
||||
If you want to install or configure plugins on your site:
|
||||
|
||||
<LinkCard
|
||||
title="Installing plugins"
|
||||
href="/plugins/installing/"
|
||||
description="Install from the marketplace, enable, configure settings."
|
||||
/>
|
||||
|
||||
## For plugin authors
|
||||
|
||||
If you want to build a plugin:
|
||||
|
||||
<CardGrid>
|
||||
<LinkCard
|
||||
title="Choosing a plugin format"
|
||||
href="/plugins/creating-plugins/choosing-a-format/"
|
||||
description="Sandboxed vs native — start here."
|
||||
/>
|
||||
<LinkCard
|
||||
title="Your first sandboxed plugin"
|
||||
href="/plugins/creating-plugins/your-first-plugin/"
|
||||
description="Build, register, and run a hello-world sandboxed plugin."
|
||||
/>
|
||||
<LinkCard
|
||||
title="Creating native plugins"
|
||||
href="/plugins/creating-native-plugins/your-first-native-plugin/"
|
||||
description="When you need React admin, PT components, or page fragments."
|
||||
/>
|
||||
</CardGrid>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Querying the registry
|
||||
description: Build a plugin directory, search page, or release feed against the registry's public discovery API with @emdash-cms/registry-client.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
This is an advanced topic for building your own software against the [plugin registry](/plugins/registry/). If you only want to install plugins on an EmDash site, you do not need any of this — enable the registry in your config and use the admin dashboard.
|
||||
|
||||
The registry's discovery side is a public, read-only API. The `@emdash-cms/registry-client` package wraps it, so you can build a plugin directory, a search page, or a release feed outside of EmDash. The client runs anywhere `fetch` is available — Node, Workers, the browser, or an Astro site.
|
||||
|
||||
<Aside type="caution" title="Experimental">
|
||||
The client targets an experimental API whose shapes may change without notice.
|
||||
Pin `@emdash-cms/registry-client` to an exact version.
|
||||
</Aside>
|
||||
|
||||
## Install
|
||||
|
||||
The `discovery` subpath carries no authentication or OAuth dependencies. Install the client and pin it to an exact version:
|
||||
|
||||
```sh
|
||||
npm install @emdash-cms/registry-client@0.3.1
|
||||
```
|
||||
|
||||
## List and resolve packages
|
||||
|
||||
The following Astro page lists every plugin in a registry:
|
||||
|
||||
```astro title="src/pages/plugins/index.astro"
|
||||
---
|
||||
import { DiscoveryClient } from "@emdash-cms/registry-client/discovery";
|
||||
|
||||
const discovery = new DiscoveryClient({
|
||||
aggregatorUrl: "https://registry.emdashcms.com",
|
||||
});
|
||||
|
||||
const { packages } = await discovery.searchPackages({ q: "", limit: 50 });
|
||||
---
|
||||
|
||||
<ul>
|
||||
{
|
||||
packages.map((pkg) => (
|
||||
<li>
|
||||
<a href={`/plugins/${pkg.did}/${pkg.slug}`}>
|
||||
{pkg.profile?.name ?? pkg.slug}
|
||||
</a>
|
||||
{pkg.latestVersion && <span>v{pkg.latestVersion}</span>}
|
||||
<p>{pkg.profile?.description}</p>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
```
|
||||
|
||||
The link uses `pkg.did`, which is always present. The publisher `handle` is best-effort and can be absent, so don't build URLs from it.
|
||||
|
||||
A package detail page fetches a plugin by its DID and slug, then fetches the latest release:
|
||||
|
||||
```typescript title="src/lib/plugin.ts"
|
||||
import { DiscoveryClient } from "@emdash-cms/registry-client/discovery";
|
||||
|
||||
const discovery = new DiscoveryClient({
|
||||
aggregatorUrl: "https://registry.emdashcms.com",
|
||||
});
|
||||
|
||||
export async function getPlugin(did: string, slug: string) {
|
||||
const pkg = await discovery.getPackage({ did, slug });
|
||||
const latest = await discovery.getLatestRelease({
|
||||
did: pkg.did,
|
||||
package: pkg.slug,
|
||||
});
|
||||
return { pkg, latest };
|
||||
}
|
||||
```
|
||||
|
||||
## Discovery methods
|
||||
|
||||
The client exposes one method per aggregator query:
|
||||
|
||||
- **`searchPackages({ q, capability?, limit?, cursor? })`** — free-text search, optionally filtered to packages declaring a given access category. Returns `{ packages, cursor? }`.
|
||||
- **`resolvePackage({ handle, slug })`** — resolve a package from a handle and slug.
|
||||
- **`getPackage({ did, slug })`** — fetch a package by its DID and slug.
|
||||
- **`listReleases({ did, package, limit?, cursor? })`** — every release for a package, newest version first.
|
||||
- **`getLatestRelease({ did, package })`** — the highest non-yanked release version.
|
||||
|
||||
## Handling untrusted records
|
||||
|
||||
The aggregator is an untrusted index that relays records it did not author, so the client validates each one at the boundary. Two rules follow from that:
|
||||
|
||||
- **The `profile` and `release` fields can be `null`.** When a relayed record fails validation, the client surfaces it as `null` rather than failing the whole call, so one malformed record does not blank a search page. Always null-check before reading `pkg.profile?.name` or `latest.release?.artifacts.package`.
|
||||
- **Validate URL schemes yourself before rendering.** Validation checks structure, not URL safety — a `uri` field can carry a `javascript:` scheme. Apply your own `http`/`https` allow-list before putting any registry-supplied URL into an `href` or `src`.
|
||||
|
||||
A non-2xx response throws `ClientResponseError` (re-exported from the package), carrying `.error`, `.description`, `.status`, and `.headers`.
|
||||
|
||||
## Filtering by host compatibility
|
||||
|
||||
A release can declare environment requirements (an EmDash or Astro version range) in its `requires` block. The `@emdash-cms/registry-client/env` subpath evaluates them, so a directory can flag releases that will not run on a given host:
|
||||
|
||||
```typescript title="src/lib/compat.ts"
|
||||
import { checkEnvCompatibility, hostEnvFromVersions } from "@emdash-cms/registry-client/env";
|
||||
import type { ValidatedReleaseView } from "@emdash-cms/registry-client/discovery";
|
||||
|
||||
const host = hostEnvFromVersions("0.17.0", "5.6.0");
|
||||
|
||||
// Pass a getLatestRelease() result. The returned array is empty when the
|
||||
// release runs on this host.
|
||||
export function envMismatches(latest: ValidatedReleaseView) {
|
||||
return checkEnvCompatibility(latest.release?.requires, host);
|
||||
}
|
||||
```
|
||||
|
||||
## What to read next
|
||||
|
||||
- [The plugin registry](/plugins/registry/) — enable and use the registry on an EmDash site
|
||||
- [Bundling and publishing](/plugins/creating-plugins/publishing/) — publish a plugin to the registry
|
||||
@@ -0,0 +1,110 @@
|
||||
---
|
||||
title: The plugin registry
|
||||
description: Install plugins from the experimental, federated EmDash plugin registry, and query it from your own site.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
The plugin registry is the decentralized successor to the central [marketplace](/plugins/installing/#from-the-marketplace). No single authority owns it or decides which plugins are listed, and the index that makes plugins discoverable — the **aggregator** — is something anyone can run. When you point your site at a registry, the admin dashboard browses and installs plugins from there instead of from the marketplace.
|
||||
|
||||
It is built on the [AT Protocol](https://atproto.com/), the network behind [Bluesky](https://bsky.app).
|
||||
|
||||
<Aside type="caution" title="Experimental">
|
||||
The registry is experimental and under active development. It is intended to
|
||||
replace the marketplace, but its behaviour and data shapes may change before
|
||||
then — pin EmDash to an exact version while you depend on it, and check the
|
||||
changelog before upgrading. The reference aggregator runs at
|
||||
`registry.emdashcms.com`.
|
||||
</Aside>
|
||||
|
||||
## Registry vs. marketplace
|
||||
|
||||
Both the [marketplace](/plugins/installing/#from-the-marketplace) and the registry let an administrator browse, install, update, and remove sandboxed plugins from the admin dashboard. The install experience — capability consent, checksum verification, the sandbox runner — is the same. They differ in who controls the catalog and who you trust.
|
||||
|
||||
| | Marketplace | Registry |
|
||||
| --- | --- | --- |
|
||||
| **Control** | One company owns and operates it | No central owner; anyone can run an aggregator |
|
||||
| **Discovery** | Served by the marketplace service | Served by an aggregator that indexes the network |
|
||||
| **Moderation** | The operator vets listings and can remove them | Labellers issue takedown and verification labels you choose to accept |
|
||||
| **Who you trust** | The marketplace operator | The aggregator you point at, plus any labellers you accept |
|
||||
|
||||
You configure one or the other for a given site. When `experimental.registry` is set, the admin's browse and install flows use the registry.
|
||||
|
||||
## Enable the registry
|
||||
|
||||
The registry requires a `sandboxRunner`, because registry plugins always run sandboxed. See [Installing Plugins](/plugins/installing/#prerequisites) for the runner setup.
|
||||
|
||||
The following configuration points a site at the reference aggregator:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
||||
experimental: {
|
||||
registry: "https://registry.emdashcms.com",
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The bare URL string is shorthand. Use the object form when you need a labeller or a release-age policy:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
emdash({
|
||||
sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
||||
experimental: {
|
||||
registry: {
|
||||
aggregatorUrl: "https://registry.emdashcms.com",
|
||||
acceptLabelers: "did:plc:emdashverification",
|
||||
policy: {
|
||||
minimumReleaseAge: "48h",
|
||||
minimumReleaseAgeExclude: ["did:plc:yourfirstpartydid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The aggregator URL must be HTTPS in production (`http://localhost` is allowed in development).
|
||||
|
||||
### Configuration options
|
||||
|
||||
- **`acceptLabelers`** — a comma-separated list of labeller DIDs forwarded to the aggregator. Labellers apply takedown and verification labels; the aggregator filters and annotates results according to the ones you accept. When unset, the aggregator applies its operator default set.
|
||||
- **`policy.minimumReleaseAge`** — hold back releases newer than this age when choosing the version to install or update to, widening the window for a takedown to land before a compromised release reaches your site. Accepts a duration string (`"24h"`, `"7d"`) or a number of seconds.
|
||||
- **`policy.minimumReleaseAgeExclude`** — DIDs exempt from the holdback, for publishers whose release tempo you have explicitly accepted (such as your own first-party plugins). Each entry is a bare publisher DID, or a `<did>/<slug>` pair to exempt a single package. Only DIDs are accepted, not handles.
|
||||
|
||||
## Browse and install
|
||||
|
||||
<Steps>
|
||||
1. Open the admin panel and navigate to **Plugins > Registry**.
|
||||
2. Browse or search. Each result shows the publisher's verified handle, the latest version, and any labels.
|
||||
3. Open a plugin to see its README, screenshots, declared access, and release history.
|
||||
4. Click **Install** and review the capability consent dialog.
|
||||
5. Confirm.
|
||||
</Steps>
|
||||
|
||||
Installation verifies the downloaded bytes against the release checksum, confirms the bundle's plugin id and version match the release, and confirms its declared capabilities match what you approved. Updates and uninstalling work as they do for [marketplace plugins](/plugins/installing/#updates).
|
||||
|
||||
### Trust model
|
||||
|
||||
The registry is decentralized, so trust is explicit. Before activating a plugin, EmDash independently verifies that the artifact bytes hash to the release checksum, that the bundle addresses the plugin id and version it claims, and that its capabilities match the consent dialog.
|
||||
|
||||
<Aside type="caution">
|
||||
Point `aggregatorUrl` only at an aggregator you operate or trust with the same
|
||||
authority as a central plugin source. `policy.minimumReleaseAge` and
|
||||
`acceptLabelers` widen the takedown window but assume the labeller system is
|
||||
operating.
|
||||
</Aside>
|
||||
|
||||
## What to read next
|
||||
|
||||
- [Installing Plugins](/plugins/installing/) — the marketplace and config-based install flows
|
||||
- [Bundling and publishing](/plugins/creating-plugins/publishing/) — publish your own plugin to the registry
|
||||
- [Querying the registry](/plugins/registry-client/) — build a plugin directory or search page against the registry's discovery API
|
||||
- [Capabilities and security](/plugins/creating-plugins/capabilities/) — the sandbox trust contract
|
||||
- [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) — the protocol design, for contributors and aggregator operators
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Upgrading plugins on your site
|
||||
description: Breaking changes for sites that install EmDash plugins, and how to adapt your project.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
This guide is for **site operators**: people who install plugins into a site. If you write plugins, see [Migrating to the plugin CLI](/plugins/creating-plugins/migrating-to-the-cli/) instead.
|
||||
|
||||
## Upgrade your dependencies
|
||||
|
||||
Update `emdash` and your plugin packages to their latest versions, then reinstall and rebuild:
|
||||
|
||||
```sh
|
||||
pnpm up emdash @emdash-cms/plugin-audit-log @emdash-cms/plugin-webhook-notifier @emdash-cms/plugin-atproto
|
||||
pnpm build
|
||||
```
|
||||
|
||||
After upgrading, your site may build and run without further changes. If the build fails or a plugin stops loading, work through the breaking changes below. Each one tells you exactly what to change.
|
||||
|
||||
For the full list of changes in each package, see its entry in the [EmDash changelog](https://github.com/emdash-cms/emdash/blob/main/CHANGELOG.md).
|
||||
|
||||
## Breaking changes
|
||||
|
||||
### Renamed: `@emdash-cms/registry-cli` is now `@emdash-cms/plugin-cli`
|
||||
|
||||
Earlier releases shipped the plugin registry CLI as `@emdash-cms/registry-cli`, with an `emdash-registry` binary.
|
||||
|
||||
The package is now `@emdash-cms/plugin-cli` and the binary is `emdash-plugin`. The old package is no longer published.
|
||||
|
||||
You only have this dependency if you publish plugins or run registry commands from your site repository. Most sites that only install plugins never had it.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
Replace the package:
|
||||
|
||||
```sh
|
||||
pnpm remove @emdash-cms/registry-cli
|
||||
pnpm add -D @emdash-cms/plugin-cli
|
||||
```
|
||||
|
||||
Update any `package.json` scripts that call the old binary, replacing `emdash-registry` with `emdash-plugin`:
|
||||
|
||||
```sh del={1} ins={2}
|
||||
emdash-registry publish --url https://example.com/my-plugin-1.0.0.tar.gz
|
||||
emdash-plugin publish --url https://example.com/my-plugin-1.0.0.tar.gz
|
||||
```
|
||||
|
||||
### Changed: published plugins use a default export
|
||||
|
||||
Earlier releases exposed first-party plugins as a named export and a factory call, for example `import { auditLogPlugin } from "@emdash-cms/plugin-audit-log"` used as `auditLogPlugin()`.
|
||||
|
||||
These plugins now provide a default export that you pass directly into `plugins:` or `sandboxed:`. There is no factory call. This affects `@emdash-cms/plugin-audit-log`, `@emdash-cms/plugin-webhook-notifier`, and `@emdash-cms/plugin-atproto`.
|
||||
|
||||
#### What should I do?
|
||||
|
||||
In `astro.config.mjs`, drop the braces around the import and the `()` after the plugin name.
|
||||
|
||||
The following example shows the change for `@emdash-cms/plugin-audit-log`, which runs in-process and goes in `plugins:`:
|
||||
|
||||
```js title="astro.config.mjs" del={1,7} ins={2,8}
|
||||
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
|
||||
import auditLog from "@emdash-cms/plugin-audit-log";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [auditLogPlugin()],
|
||||
plugins: [auditLog],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Apply the same two edits to the other packages. `@emdash-cms/plugin-atproto` and `@emdash-cms/plugin-webhook-notifier` are sandboxed plugins, so they go in `sandboxed:` instead of `plugins:`; the import change is identical.
|
||||
|
||||
| Package | Default export binding |
|
||||
| ------------------------------------- | ---------------------- |
|
||||
| `@emdash-cms/plugin-audit-log` | `auditLog` |
|
||||
| `@emdash-cms/plugin-webhook-notifier` | `webhookNotifier` |
|
||||
| `@emdash-cms/plugin-atproto` | `atproto` |
|
||||
|
||||
<Aside type="note" title="Where did the factory argument go?">
|
||||
The old factory call accepted per-install configuration. That configuration
|
||||
now lives in the admin UI, under the plugin's settings page (stored in KV).
|
||||
Set it there instead of in `astro.config.mjs`. There is nothing to pass at
|
||||
the call site, so the `()` is no longer needed.
|
||||
</Aside>
|
||||
|
||||
## After upgrading
|
||||
|
||||
If a third-party plugin still ships a named export and factory call, it has not been updated for this release. Check its changelog. All first-party plugins listed above use the default-export shape.
|
||||
@@ -0,0 +1,398 @@
|
||||
---
|
||||
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 and working with preview, settings, menus, taxonomies, widget areas, sections, and search.
|
||||
|
||||
## 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. The following example loads all posts and checks for an error:
|
||||
|
||||
```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
|
||||
|
||||
The `options` parameter accepts the following filter:
|
||||
|
||||
```ts
|
||||
interface CollectionFilter {
|
||||
status?: "draft" | "published" | "archived";
|
||||
limit?: number;
|
||||
cursor?: string; // Keyset pagination — pass a previous `nextCursor`
|
||||
offset?: number; // Offset pagination — skip N entries (use with `limit`)
|
||||
where?: Record<string, string | string[]>; // Filter by field or taxonomy
|
||||
}
|
||||
```
|
||||
|
||||
#### Returns
|
||||
|
||||
The function resolves to a `CollectionResult`:
|
||||
|
||||
```ts
|
||||
interface CollectionResult<T> {
|
||||
entries: ContentEntry<T>[]; // Empty array if error or none found
|
||||
error?: Error; // Set if query failed
|
||||
nextCursor?: string; // Cursor for the next keyset page, if any
|
||||
hasMore?: boolean; // Whether more entries exist beyond this page (when `limit` is set)
|
||||
}
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
The following examples filter by status and taxonomy, limit results, and handle errors:
|
||||
|
||||
```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" },
|
||||
});
|
||||
|
||||
// Numbered archive page (e.g. /page/3) with offset pagination
|
||||
const perPage = 20;
|
||||
const page = Number(Astro.params.page ?? 1);
|
||||
const { entries: pagePosts, hasMore } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
limit: perPage,
|
||||
offset: (page - 1) * perPage,
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
|
||||
// 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. The following example loads a post and redirects when it is missing:
|
||||
|
||||
```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 |
|
||||
| `options` | `{ locale?: string }` | Optional. Locale for slug resolution |
|
||||
|
||||
Preview mode is handled automatically — the middleware detects `_preview` tokens and serves draft content via `AsyncLocalStorage`. The optional `options` parameter only accepts a `locale` for slug resolution; preview state requires no parameter.
|
||||
|
||||
#### Returns
|
||||
|
||||
The function resolves to an `EntryResult`:
|
||||
|
||||
```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
|
||||
|
||||
The following examples fetch by slug and ID, read preview state, and distinguish errors from not-found:
|
||||
|
||||
```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`
|
||||
|
||||
Query functions return entries in the following shape:
|
||||
|
||||
```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
|
||||
|
||||
## Preview system
|
||||
|
||||
### `generatePreviewToken()`
|
||||
|
||||
Generate a preview token for draft content. The following example creates a token that expires in one hour:
|
||||
|
||||
```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 and read its payload:
|
||||
|
||||
```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 whether a request includes a preview token, then read it:
|
||||
|
||||
```ts
|
||||
import { isPreviewRequest, getPreviewToken } from "emdash";
|
||||
|
||||
if (isPreviewRequest(Astro.url)) {
|
||||
const token = getPreviewToken(Astro.url);
|
||||
// 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
|
||||
|
||||
Read site-wide settings with `getSiteSettings` and `getSiteSetting`:
|
||||
|
||||
```ts
|
||||
import { getSiteSettings, getSiteSetting } from "emdash";
|
||||
|
||||
// Get all settings
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
// Get single setting
|
||||
const title = await getSiteSetting("title");
|
||||
```
|
||||
|
||||
Settings are read-only from the runtime API. Use the admin API to update them.
|
||||
|
||||
## Menus
|
||||
|
||||
Fetch navigation menus and iterate their items, including nested children:
|
||||
|
||||
```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
|
||||
|
||||
Fetch taxonomy terms, a single term, an entry's terms, or entries by term:
|
||||
|
||||
```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
|
||||
|
||||
Fetch widget areas and the widgets they contain:
|
||||
|
||||
```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
|
||||
|
||||
Fetch sections and filter them:
|
||||
|
||||
```ts
|
||||
import { getSection, getSections } from "emdash";
|
||||
|
||||
// Get all sections (paginated)
|
||||
const { items, nextCursor } = await getSections();
|
||||
|
||||
// Filter sections
|
||||
const { items: themeSections } = await getSections({ source: "theme" });
|
||||
const { items: results } = await getSections({ search: "newsletter" });
|
||||
|
||||
// Get a single section by slug
|
||||
const cta = await getSection("newsletter-cta");
|
||||
```
|
||||
|
||||
`getSections(options?)` returns `{ items: Section[]; nextCursor?: string }`. Options are `source` (`"theme" | "user" | "import"`), `search`, `limit` (default 50, max 100), and `cursor`.
|
||||
|
||||
## Search
|
||||
|
||||
Run a global search across collections. Results include highlighted snippets:
|
||||
|
||||
```ts
|
||||
import { search } from "emdash";
|
||||
|
||||
const results = await search("hello world", {
|
||||
collections: ["posts", "pages"],
|
||||
status: "published",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// search() resolves to { items, nextCursor? }
|
||||
results.items.forEach(result => {
|
||||
console.log(result.title);
|
||||
console.log(result.snippet); // Contains <mark> tags
|
||||
console.log(result.score);
|
||||
});
|
||||
|
||||
// Paginate: pass the previous nextCursor back as `cursor` to get the next page.
|
||||
// nextCursor is undefined once there are no more results.
|
||||
if (results.nextCursor) {
|
||||
const next = await search("hello world", {
|
||||
collections: ["posts", "pages"],
|
||||
limit: 20,
|
||||
cursor: results.nextCursor,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Error handling
|
||||
|
||||
EmDash exports error classes for handling specific failures. The following example catches validation and schema errors:
|
||||
|
||||
```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);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,586 @@
|
||||
---
|
||||
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. Install it with the following command:
|
||||
|
||||
```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 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 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 confirm you have seen the current state before overwriting it.
|
||||
|
||||
#### `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 |
|
||||
| `--locale` | Content locale |
|
||||
| `--translation-of` | ID of a content item to link this as a translation of |
|
||||
| `--draft` | Keep as draft instead of auto-publishing |
|
||||
|
||||
Provide data via exactly one of `--data`, `--file`, or `--stdin`. New items are auto-published unless `--draft` is set.
|
||||
|
||||
#### `content update <collection> <id>`
|
||||
|
||||
You must provide the `_rev` token from a prior `get` to prove you have seen the current state. This prevents overwriting changes you have not seen. The following steps read an item, then update it with that token:
|
||||
|
||||
```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 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 your local `.env` file with `--write`. The same `.env` file is
|
||||
read by Node and, in local development, by Wrangler and the Cloudflare Vite
|
||||
plugin:
|
||||
|
||||
```bash
|
||||
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. This is useful in CI for verifying the right key was deployed. The following command prints a key's fingerprint:
|
||||
|
||||
```bash
|
||||
npx emdash secrets fingerprint emdash_enc_v1_...
|
||||
```
|
||||
|
||||
## Generated Files
|
||||
|
||||
### `.emdash/types.ts`
|
||||
|
||||
The `emdash types` command generates TypeScript interfaces for each collection:
|
||||
|
||||
```ts title=".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`
|
||||
|
||||
The command also writes a raw schema export for tooling:
|
||||
|
||||
```json title=".emdash/schema.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
|
||||
|
||||
Add the CLI commands as `package.json` scripts for convenience:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "emdash dev",
|
||||
"types": "emdash types",
|
||||
"export-seed": "emdash export-seed",
|
||||
"db:reset": "rm -f data.db"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------------------- |
|
||||
| `0` | Success |
|
||||
| `1` | Error (configuration, network, database) |
|
||||
@@ -0,0 +1,723 @@
|
||||
---
|
||||
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 in `astro.config.mjs`:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local, s3 } from "emdash/astro";
|
||||
import { sqlite, libsql } 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. Choose one adapter:
|
||||
|
||||
```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. Choose one adapter:
|
||||
|
||||
```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.
|
||||
|
||||
### `objectCache`
|
||||
|
||||
**Optional.** Caches content and configuration query results in a key/value store so reads are served without querying the database on every request. Disabled when omitted. Choose one adapter:
|
||||
|
||||
```js
|
||||
// Cloudflare KV (shared across all isolates)
|
||||
import { kvCache } from "@emdash-cms/cloudflare";
|
||||
objectCache: kvCache({ binding: "CACHE" });
|
||||
|
||||
// In-memory (Node.js / development)
|
||||
import { memoryCache } from "emdash/astro";
|
||||
objectCache: memoryCache();
|
||||
```
|
||||
|
||||
See [Object Cache](/deployment/object-cache/) for setup and options.
|
||||
|
||||
### `plugins`
|
||||
|
||||
**Optional.** Array of EmDash plugins. The following example registers one plugin:
|
||||
|
||||
```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. The following example adds Arabic and Japanese:
|
||||
|
||||
```js
|
||||
emdash({
|
||||
fonts: {
|
||||
scripts: ["arabic", "japanese"],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
The available scripts are `arabic`, `armenian`, `bengali`, `chinese-simplified`, `chinese-traditional`, `chinese-hongkong`, `devanagari`, `ethiopic`, `farsi`, `georgian`, `gujarati`, `gurmukhi`, `hebrew`, `japanese`, `kannada`, `khmer`, `korean`, `lao`, `malayalam`, `myanmar`, `oriya`, `sinhala`, `tamil`, `telugu`, `thai`, and `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.** An authentication adapter. EmDash's built-in login is passkeys; setting `auth` replaces them with an external provider. The Cloudflare Access adapter, `access()`, is provided by `@emdash-cms/cloudflare`:
|
||||
|
||||
```js
|
||||
import { access } from "@emdash-cms/cloudflare";
|
||||
|
||||
emdash({
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "your-app-audience-tag",
|
||||
roleMapping: {
|
||||
Admins: 50,
|
||||
Editors: 40,
|
||||
},
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Options for `access()`:
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | --------- | ---------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| `teamDomain` | `string` | required | Your Cloudflare Access team domain |
|
||||
| `audience` | `string` | — | Application Audience (AUD) tag. On Workers, prefer `audienceEnvVar`. |
|
||||
| `audienceEnvVar` | `string` | `"CF_ACCESS_AUDIENCE"` | Environment variable to read the audience tag from at runtime |
|
||||
| `autoProvision` | `boolean` | `true` | Create an EmDash user on first login |
|
||||
| `defaultRole` | `number` | `30` | Role level for users not matched by `roleMapping` (see [User roles](/guides/authentication/#user-roles)) |
|
||||
| `syncRoles` | `boolean` | `false` | Re-apply `roleMapping` on every login instead of only at provisioning |
|
||||
| `roleMapping` | `object` | — | Map IdP group names to EmDash role levels; first match wins |
|
||||
|
||||
<Aside>
|
||||
When an auth adapter is configured it becomes the only auth method: passkeys are disabled.
|
||||
</Aside>
|
||||
|
||||
### `authProviders`
|
||||
|
||||
**Optional.** An array of pluggable login providers (top-level, alongside `auth`). Each entry is the result of calling a provider factory, as shown below:
|
||||
|
||||
```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 account login (Bluesky and the wider AT Protocol network). 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).
|
||||
|
||||
### `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).
|
||||
|
||||
The following example sets the public origin:
|
||||
|
||||
```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` is the single option for the public origin. It 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).
|
||||
|
||||
The following example declares one extra origin in config:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
siteUrl: "https://example.com",
|
||||
allowedOrigins: ["https://preview.example.com"],
|
||||
})
|
||||
```
|
||||
|
||||
The equivalent values can also come from environment variables:
|
||||
|
||||
```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 Astro’s rebuilt URL can be wrong.
|
||||
</Aside>
|
||||
|
||||
The following configuration sets `allowedDomains`, `vite.server.allowedHosts`, and `siteUrl` together for a reverse-proxy deployment:
|
||||
|
||||
```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".
|
||||
|
||||
The following example trusts the `x-real-ip` header set by nginx, Caddy, or Traefik:
|
||||
|
||||
```js
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
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. The following example prefers Fly.io's header and falls back to `x-forwarded-for`:
|
||||
|
||||
```js
|
||||
emdash({
|
||||
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). The following example raises the limit to 100 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.
|
||||
|
||||
### `toolbar`
|
||||
|
||||
**Optional.** Controls how the editor toolbar (the floating pill on public pages) is delivered. Defaults to `"server"`.
|
||||
|
||||
| Value | Behavior |
|
||||
| ----- | -------- |
|
||||
| `"server"` (default) | The toolbar is injected server-side into every HTML response rendered for an authenticated editor. |
|
||||
| `"client"` | Public HTML is identical for every visitor. A tiny bootstrap script shows an "Edit" pill in browsers that have logged into the admin; clicking it verifies the session and reloads the page with an `_edit` query param, which is always rendered fresh (never cached) with the full toolbar. |
|
||||
| `false` | Never render the toolbar or the bootstrap script. |
|
||||
|
||||
```js
|
||||
emdash({
|
||||
toolbar: "client",
|
||||
})
|
||||
```
|
||||
|
||||
Use `"client"` when your public HTML is served through a shared cache (Cloudflare Cache Everything / [Workers Cache](https://developers.cloudflare.com/workers/cache/), Fastly, Varnish, …). With server-side injection, an editor browsing the public site receives the cached anonymous variant — without the toolbar — whenever an anonymous visitor primed the cache first, so the toolbar appears and disappears with cache state. In client mode nothing session-specific is injected into shareable HTML, so the cache stays fully effective and the toolbar is reliable.
|
||||
|
||||
Notes on `"client"` mode:
|
||||
|
||||
- Logged-out visitors who open a shared `?_edit` URL are redirected to the canonical URL, so the param can't leak drafts or prime extra cache entries with page content.
|
||||
- The "logged in" signal is a non-secret `localStorage` flag set by the admin; the pill verifies the real session before entering the edit view.
|
||||
- The bootstrap is a small inline `<script>`. If your site sends a strict `Content-Security-Policy` without `'unsafe-inline'`, add a hash for it — the same applies to the server-injected toolbar.
|
||||
- EmDash injects nothing session-specific — but if your own templates branch on `Astro.locals.user` (e.g. an "Admin" nav link for logged-in users), that variance is still in your HTML and still fragments the cache.
|
||||
|
||||
In every mode, the toolbar can be dismissed in the browser via its × button (per-browser, until the next time an editor opens the admin). Preview and edit-mode responses always render server-side with `Cache-Control: private, no-store`.
|
||||
|
||||
### `experimental`
|
||||
|
||||
**Optional.** Opt-in features whose behaviour or wire format may change, or be removed, in a minor release. Each field is independently enabled.
|
||||
|
||||
<Aside type="caution">
|
||||
Experimental options are unstable. Pin EmDash to an exact version when you depend on one, and check the changelog before upgrading.
|
||||
</Aside>
|
||||
|
||||
#### `experimental.registry`
|
||||
|
||||
**Optional.** Point the admin dashboard's plugin browse and install flows at a federated [plugin registry](/plugins/registry/) instead of the central marketplace. Requires `sandboxRunner`, because registry plugins run sandboxed.
|
||||
|
||||
Pass a bare aggregator URL string, or an object when you need a labeller or a release-age policy. The following example uses the object form:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
sandboxRunner: "@emdash-cms/sandbox-cloudflare",
|
||||
experimental: {
|
||||
registry: {
|
||||
aggregatorUrl: "https://registry.emdashcms.com",
|
||||
acceptLabelers: "did:plc:emdashverification",
|
||||
policy: {
|
||||
minimumReleaseAge: "48h",
|
||||
minimumReleaseAgeExclude: ["did:plc:yourfirstpartydid"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| --------------------------------- | ---------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `aggregatorUrl` | `string` | Aggregator origin where the registry's XRPC endpoints are mounted. HTTPS in production. |
|
||||
| `acceptLabelers` | `string` | Comma-separated labeller DIDs forwarded to the aggregator for takedown and verification labels. |
|
||||
| `policy.minimumReleaseAge` | `string \| number` | Hold back releases newer than this age. Duration string (`"48h"`, `"7d"`) or seconds. |
|
||||
| `policy.minimumReleaseAgeExclude` | `string[]` | DIDs (or `<did>/<slug>` pairs) exempt from the holdback. |
|
||||
|
||||
See [The plugin registry](/plugins/registry/) for the full workflow, trust model, and how to query the registry from your own site.
|
||||
|
||||
## Database adapters
|
||||
|
||||
Import the adapters from `emdash/db`:
|
||||
|
||||
```js
|
||||
import { sqlite, libsql, postgres } from "emdash/db";
|
||||
```
|
||||
|
||||
### `sqlite(config)`
|
||||
|
||||
SQLite database using better-sqlite3. The following example connects to a local file:
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | -------- | ----------------------------- |
|
||||
| `url` | `string` | File path with `file:` prefix |
|
||||
|
||||
```js
|
||||
sqlite({ url: "file:./data.db" });
|
||||
```
|
||||
|
||||
### `libsql(config)`
|
||||
|
||||
libSQL database. The following example connects to a remote 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) |
|
||||
|
||||
The following example connects with a connection string:
|
||||
|
||||
```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 |
|
||||
|
||||
The following example shows a basic binding and one with read replicas enabled:
|
||||
|
||||
```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 `local` and `s3` from `emdash/astro`. The `r2` adapter is imported from `@emdash-cms/cloudflare`:
|
||||
|
||||
```js
|
||||
import emdash, { local, s3 } from "emdash/astro";
|
||||
import { r2 } from "@emdash-cms/cloudflare";
|
||||
```
|
||||
|
||||
### `local(config)`
|
||||
|
||||
Local filesystem storage. The following example serves uploads from a local directory:
|
||||
|
||||
| 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. The following example uses an R2 binding with a public URL:
|
||||
|
||||
| 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`) |
|
||||
|
||||
The following examples resolve all fields from the environment, mix config and environment, or pass every field explicitly:
|
||||
|
||||
```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
|
||||
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.
|
||||
|
||||
## Object cache adapters
|
||||
|
||||
Pass one of these to the [`objectCache`](#objectcache) option.
|
||||
|
||||
### `kvCache(config)`
|
||||
|
||||
Cloudflare KV backend, shared across all isolates. Import from `@emdash-cms/cloudflare`.
|
||||
|
||||
```js
|
||||
kvCache({
|
||||
binding: "CACHE", // KV binding name (required)
|
||||
defaultTtl: 3600, // entry TTL in seconds (optional, KV minimum 60)
|
||||
revalidate: 1000, // cross-isolate staleness window in ms (optional)
|
||||
timeout: 2000, // per-op timeout in ms before a miss (optional, 0 disables)
|
||||
keyPrefix: "em", // cache key prefix (optional)
|
||||
})
|
||||
```
|
||||
|
||||
### `memoryCache(config?)`
|
||||
|
||||
In-process backend for Node.js and development. Import from `emdash/astro`.
|
||||
|
||||
```js
|
||||
memoryCache({
|
||||
defaultTtl: 3600, // entry TTL in seconds (optional)
|
||||
revalidate: 1000, // staleness window in ms (optional)
|
||||
maxEntries: 1000, // max cached keys before eviction (optional)
|
||||
keyPrefix: "em", // cache key prefix (optional)
|
||||
})
|
||||
```
|
||||
|
||||
See [Object Cache](/deployment/object-cache/) for setup and behavior.
|
||||
|
||||
## 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 takes no arguments:
|
||||
|
||||
```ts
|
||||
emdashLoader();
|
||||
```
|
||||
|
||||
## 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_TURNSTILE_SECRET_KEY` | Cloudflare Turnstile secret key (falls back to `TURNSTILE_SECRET_KEY`). When set, comment submissions must include a valid Turnstile token — pair it with the `turnstileSiteKey` prop on `<CommentForm>`. |
|
||||
| `EMDASH_URL` | Remote EmDash URL for schema sync |
|
||||
|
||||
Generate an encryption key with the following command:
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
```
|
||||
|
||||
## package.json configuration
|
||||
|
||||
Templates and sites can declare optional metadata under an `emdash` key in `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"emdash": {
|
||||
"label": "My Blog Template",
|
||||
"seed": ".emdash/seed.json",
|
||||
"url": "https://my-site.pages.dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------- | ---------------------------------- |
|
||||
| `label` | Template name for display |
|
||||
| `seed` | Path to seed JSON file |
|
||||
| `url` | Remote URL for schema sync |
|
||||
|
||||
## TypeScript configuration
|
||||
|
||||
EmDash generates types in `.emdash/types.ts`. Add a path alias to your `tsconfig.json`:
|
||||
|
||||
```json title="tsconfig.json"
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@emdash-cms/types": ["./.emdash/types.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Generate types with the following command:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
@@ -0,0 +1,441 @@
|
||||
---
|
||||
title: Field Types Reference
|
||||
description: Complete reference for all EmDash field types.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash supports 16 field types for defining content schemas. Each type maps to a SQLite column type and provides appropriate admin UI.
|
||||
|
||||
## Overview
|
||||
|
||||
The following table lists every field type and its SQLite column:
|
||||
|
||||
| Type | SQLite Column | Description |
|
||||
| -------------- | ------------- | -------------------------- |
|
||||
| `string` | TEXT | Short text input |
|
||||
| `text` | TEXT | Multi-line text |
|
||||
| `url` | TEXT | URL value |
|
||||
| `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 |
|
||||
| `repeater` | JSON | Repeating group of fields |
|
||||
|
||||
## 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,
|
||||
}
|
||||
```
|
||||
|
||||
The value is stored as a JSON array of Portable Text blocks, for example:
|
||||
|
||||
```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 [Portable Text rendering components](/plugins/creating-native-plugins/portable-text-components/).
|
||||
|
||||
<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)
|
||||
|
||||
The value is stored as an object with the media reference and its metadata:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXK5MZSN...",
|
||||
"url": "https://cdn.example.com/image.jpg",
|
||||
"alt": "Description",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
```
|
||||
|
||||
### `file`
|
||||
|
||||
Reference to an uploaded file such as a document or PDF.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "document",
|
||||
label: "Document",
|
||||
type: "file",
|
||||
}
|
||||
```
|
||||
|
||||
The value is stored as an object with the file reference and its metadata:
|
||||
|
||||
```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)
|
||||
|
||||
A single reference is stored as the target entry ID:
|
||||
|
||||
```json
|
||||
"01HXK5MZSN..."
|
||||
```
|
||||
|
||||
Multiple references are stored as an array of entry IDs:
|
||||
|
||||
```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`
|
||||
- `primary_byline_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `published_at`
|
||||
- `scheduled_at`
|
||||
- `deleted_at`
|
||||
- `version`
|
||||
- `live_revision_id`
|
||||
- `draft_revision_id`
|
||||
- `terms`
|
||||
- `bylines`
|
||||
- `byline`
|
||||
|
||||
## TypeScript types
|
||||
|
||||
Import the field type definitions for programmatic use:
|
||||
|
||||
```ts
|
||||
import type { FieldType, Field, CreateFieldInput } from "emdash";
|
||||
|
||||
const fieldTypes: FieldType[] = [
|
||||
"string",
|
||||
"text",
|
||||
"url",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"datetime",
|
||||
"select",
|
||||
"multiSelect",
|
||||
"portableText",
|
||||
"image",
|
||||
"file",
|
||||
"reference",
|
||||
"json",
|
||||
"slug",
|
||||
"repeater",
|
||||
];
|
||||
```
|
||||
@@ -0,0 +1,780 @@
|
||||
---
|
||||
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
|
||||
|
||||
The following table lists every hook, what triggers it, what it can modify, and whether it is exclusive:
|
||||
|
||||
| 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 |
|
||||
| `content:afterPublish` | After content is published | Nothing | No |
|
||||
| `content:afterUnpublish` | After content is unpublished | Nothing | No |
|
||||
| `content:afterRestore` | After content is restored | Nothing | No |
|
||||
| `content:afterSchedule` | After content is scheduled | Nothing | No |
|
||||
| `content:afterUnschedule` | After content is unscheduled | 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}`);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `content:afterRestore`
|
||||
|
||||
Runs after trashed content is restored. Requires `content:read` capability.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:afterRestore": async (event, ctx) => {
|
||||
ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `content:afterSchedule`
|
||||
|
||||
Runs after content is scheduled for future publishing. Requires `content:read` capability.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:afterSchedule": async (event, ctx) => {
|
||||
ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `content:afterUnschedule`
|
||||
|
||||
Runs after scheduled content is unscheduled. Requires `content:read` capability.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:afterUnschedule": async (event, ctx) => {
|
||||
ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface ContentStateChangeEvent {
|
||||
content: Record<string, unknown>;
|
||||
collection: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
No return value expected.
|
||||
|
||||
## 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 run in order: `email:beforeSend`, then `email:deliver`, then `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 run in order: `comment:beforeCreate`, then `comment:moderate`, then `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: "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document";
|
||||
href: string;
|
||||
hreflang?: string;
|
||||
key?: string;
|
||||
}
|
||||
| {
|
||||
kind: "jsonld";
|
||||
id?: string;
|
||||
graph: Record<string, unknown> | Array<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: ... }
|
||||
```
|
||||
@@ -0,0 +1,772 @@
|
||||
---
|
||||
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 | Contributor (20) |
|
||||
| Content 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 45 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 navigation menus. Menus are per-locale: pass `locale` to return one locale's
|
||||
rows only, or omit it to list every locale variant.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `locale` | `string` | No | Filter by locale (omit for all locale variants) |
|
||||
|
||||
**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. When the same menu name exists in multiple locales,
|
||||
pass `locale` to resolve the intended translation.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name (e.g. `main`, `footer`) |
|
||||
| `locale` | `string` | No | Locale to resolve the menu for |
|
||||
|
||||
**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. Menus are
|
||||
per-locale, so pass `locale` when the same menu name exists in multiple
|
||||
translations. Add items afterwards with `menu_set_items`. If `translationOf` is
|
||||
set, `locale` must also be set.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Stable identifier (`/^[a-z][a-z0-9_]*$/`) |
|
||||
| `label` | `string` | Yes | Display name for the admin |
|
||||
| `locale` | `string` | No | Locale for this menu (e.g. `fr-fr`) |
|
||||
| `translationOf` | `string` | No | Existing menu id to create this locale variant from |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `menu_update`
|
||||
|
||||
Update a menu's label. The `name` (stable identifier) cannot be changed. On
|
||||
multi-locale installs, pass `locale` so the correct translation is updated.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name to update |
|
||||
| `label` | `string` | Yes | New display label |
|
||||
| `locale` | `string` | No | Locale of the menu to update |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `menu_delete`
|
||||
|
||||
Delete a menu and all its items. Cannot be undone. On multi-locale installs,
|
||||
pass `locale` so only the intended translation is removed.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name to delete |
|
||||
| `locale` | `string` | No | Locale of the menu 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. On multi-locale installs, pass `locale` so only the intended
|
||||
translation is rewritten.
|
||||
|
||||
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 |
|
||||
| `locale` | `string` | No | Locale of the menu to rewrite |
|
||||
| `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
|
||||
|
||||
Most MCP clients handle this for you; this section is for building an MCP client against EmDash directly. Clients that support OAuth 2.1 discover how to authenticate from two metadata documents the server publishes:
|
||||
|
||||
### Protected Resource Metadata
|
||||
|
||||
Request the protected resource metadata at the following endpoint:
|
||||
|
||||
```http
|
||||
GET /.well-known/oauth-protected-resource
|
||||
```
|
||||
|
||||
The server responds with the resource identifier, its authorization server, and supported scopes:
|
||||
|
||||
```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
|
||||
|
||||
Request the authorization server metadata at the following endpoint:
|
||||
|
||||
```http
|
||||
GET /.well-known/oauth-authorization-server/_emdash
|
||||
```
|
||||
|
||||
The server responds with the endpoints, scopes, and grant types it supports:
|
||||
|
||||
```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`. The message is prefixed with a stable `[CODE]`, and the same code is repeated in `_meta.code`:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{ "type": "text", "text": "[NOT_FOUND] Collection 'nonexistent' not found" }],
|
||||
"isError": true,
|
||||
"_meta": { "code": "NOT_FOUND" }
|
||||
}
|
||||
```
|
||||
|
||||
Scope and permission errors use the same tool error envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [
|
||||
{ "type": "text", "text": "[INSUFFICIENT_SCOPE] Insufficient scope: requires content:write" }
|
||||
],
|
||||
"isError": true,
|
||||
"_meta": { "code": "INSUFFICIENT_SCOPE" }
|
||||
}
|
||||
```
|
||||
|
||||
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code `-32603` (Internal error) without leaking implementation details.
|
||||
@@ -0,0 +1,750 @@
|
||||
---
|
||||
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 is no theme API or abstraction layer. A theme is a site shipped as a template. The seed file tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run.
|
||||
- **The seed file declares the content model.** It lists exactly what fields each collection needs. Build on the standard **posts** and **pages** collections and add fields and taxonomies as the 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
|
||||
|
||||
A theme uses the following 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}
|
||||
priority
|
||||
/>
|
||||
)}
|
||||
<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>
|
||||
|
||||
Use `priority` only for the likely above-the-fold image on a page, such as a hero or first card image. It renders the image with `loading="eager"` and `fetchpriority="high"`. `loading` controls whether the browser may defer loading, while `fetchpriority` hints how important the request is once the browser discovers it.
|
||||
|
||||
## 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 the pages collection in the seed file:
|
||||
|
||||
```json title=".emdash/seed.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. A remote image is referenced by URL:
|
||||
|
||||
```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 by filename:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
A GitHub-hosted theme is installed with the `github:` template prefix:
|
||||
|
||||
```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 }} />
|
||||
```
|
||||
|
||||
Render the wrapper component in a page:
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
Use the `_key` value as an anchor in the block 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
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
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>
|
||||
A theme declares exactly which collections and fields it needs via the seed file. Most themes
|
||||
build on the standard **posts** and **pages** collections, adding fields and taxonomies as needed
|
||||
rather than inventing entirely new content types.
|
||||
</Aside>
|
||||
|
||||
## Theme Structure
|
||||
|
||||
A theme has the following layout:
|
||||
|
||||
```
|
||||
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
|
||||
|
||||
Creating a site from a theme follows these steps:
|
||||
|
||||
1. `create-astro` scaffolds the project from the template
|
||||
2. 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
|
||||
|
||||
The following command scaffolds a site from an official theme template:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template @emdash-cms/template-blog
|
||||
```
|
||||
|
||||
Community themes hosted on GitHub use the `github:` template prefix:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template github:user/emdash-portfolio
|
||||
```
|
||||
|
||||
After scaffolding, install dependencies and start the dev server:
|
||||
|
||||
```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 performs these steps:
|
||||
|
||||
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
|
||||
|
||||
<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 Workers with D1 and R2, append `-cloudflare` to the template name, as in the following commands:
|
||||
|
||||
```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, the 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. After that, manage the content model in the admin panel or with the CLI.
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,456 @@
|
||||
---
|
||||
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">
|
||||
Template porting is a mechanical conversion that AI coding agents handle well. Provide the agent
|
||||
with the WordPress theme files and the concept mapping tables in this guide to generate a first
|
||||
draft of the Astro components. Review and refine the output before shipping.
|
||||
</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 the component 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
|
||||
@@ -0,0 +1,677 @@
|
||||
---
|
||||
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
|
||||
|
||||
A seed file has the following top-level shape:
|
||||
|
||||
```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
|
||||
|
||||
The `meta` object holds optional descriptive metadata about the seed:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"name": "Blog Starter",
|
||||
"description": "A simple blog with posts, pages, and categories",
|
||||
"author": "EmDash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
The `settings` object holds 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
|
||||
|
||||
Each collection definition creates a content type 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
|
||||
|
||||
Taxonomies are 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
|
||||
|
||||
The `menus` array defines navigation menus that are 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
|
||||
|
||||
The `redirects` array defines redirect rules that 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
|
||||
|
||||
The `widgetAreas` array defines 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
|
||||
|
||||
Sections are reusable content blocks that editors 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
|
||||
|
||||
The `content` object holds sample content entries 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 that:
|
||||
|
||||
- Required fields are present
|
||||
- Slugs are valid for their type (collection and field slugs allow lowercase letters, digits, and underscores; other slugs also allow hyphens)
|
||||
- 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
|
||||
|
||||
The seed file at `.emdash/seed.json`, `package.json#emdash.seed`, or `seed/seed.json` is inlined into the build and applied on the first request when the database is empty. To export an existing site's schema (and optionally its content) as a seed file:
|
||||
|
||||
```bash
|
||||
# `mkdir -p` because .emdash/ may not exist yet on a fresh project
|
||||
mkdir -p .emdash
|
||||
|
||||
# Export the current schema as a seed file
|
||||
npx emdash export-seed > .emdash/seed.json
|
||||
|
||||
# Export with content
|
||||
npx emdash export-seed --with-content > .emdash/seed.json
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Creating Themes](/themes/creating-themes/)** — Build a complete theme
|
||||
- **[Themes Overview](/themes/overview/)** — How themes work
|
||||
@@ -0,0 +1,100 @@
|
||||
---
|
||||
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 a CMS built for Astro. It gives editors an admin interface, serves content to your Astro site at runtime, and runs on a range of databases and hosts.
|
||||
|
||||
## 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 and is queried through Astro's Live Content Collections within the same process.
|
||||
|
||||
### 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">
|
||||
Generate TypeScript types from your content model for full autocomplete,
|
||||
from query to template.
|
||||
</Card>
|
||||
<Card title="Live Updates" icon="star">
|
||||
Built on Astro's Live Content Collections. Content changes appear instantly.
|
||||
</Card>
|
||||
<Card title="Cloud-Portable" icon="setting">
|
||||
Runs on Cloudflare Workers with D1 and R2, and on Node.js with SQLite, libSQL, or
|
||||
PostgreSQL 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 |
|
||||
| **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 | Native or sandboxed hooks |
|
||||
|
||||
## Cloudflare Deployment
|
||||
|
||||
EmDash runs on any platform with a supported SQL database (SQLite, libSQL, or PostgreSQL) 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>
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
const frontmatter = {
|
||||
title: "404",
|
||||
template: "splash",
|
||||
editUrl: false,
|
||||
pagefind: false,
|
||||
hero: {
|
||||
tagline: Astro.locals.t("404.text"),
|
||||
actions: [],
|
||||
},
|
||||
};
|
||||
---
|
||||
|
||||
<StarlightPage frontmatter={frontmatter} />
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { APIRoute } from "astro";
|
||||
import { getCollection, getEntry } from "astro:content";
|
||||
import config from "virtual:starlight/user-config";
|
||||
|
||||
export const prerender = true;
|
||||
|
||||
type SidebarItem = NonNullable<typeof config.sidebar>[number];
|
||||
type DocsEntry = Awaited<ReturnType<typeof getCollection<"docs">>>[number];
|
||||
type Link = { label: string; href: string };
|
||||
|
||||
const TRIM_SLASHES = /^\/|\/$/g;
|
||||
const ABSOLUTE_URL = /^https?:\/\//;
|
||||
|
||||
export const GET: APIRoute = async ({ site }) => {
|
||||
// Pre-load the docs collection so autogenerated groups can resolve
|
||||
// without one `getEntry` call per page.
|
||||
const docs = await getCollection("docs");
|
||||
|
||||
const lang = config.defaultLocale.lang;
|
||||
const title = (lang ? config.title[lang] : undefined) ?? Object.values(config.title)[0] ?? "";
|
||||
const lines: string[] = [`# ${title}`];
|
||||
if (config.tagline) {
|
||||
lines.push("", `> ${config.tagline}`);
|
||||
}
|
||||
|
||||
for (const item of config.sidebar ?? []) {
|
||||
const links = await resolveItem(item, docs);
|
||||
if (links.length === 0) continue;
|
||||
|
||||
const groupLabel = typeof item !== "string" && "label" in item ? item.label : undefined;
|
||||
if (groupLabel && typeof item !== "string" && ("items" in item || "autogenerate" in item)) {
|
||||
lines.push("", `## ${groupLabel}`, "");
|
||||
} else {
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
for (const link of links) {
|
||||
lines.push(`- [${link.label}](${absUrl(link.href, site)})`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(lines.join("\n") + "\n", {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
};
|
||||
|
||||
async function resolveItem(item: SidebarItem, docs: DocsEntry[]): Promise<Link[]> {
|
||||
// Shorthand: a string is a slug reference.
|
||||
if (typeof item === "string") {
|
||||
const entry = getEntry("docs", item);
|
||||
return entry ? [entryLink(entry)] : [];
|
||||
}
|
||||
|
||||
if ("link" in item && item.link) {
|
||||
return [{ label: item.label, href: item.link }];
|
||||
}
|
||||
|
||||
if ("slug" in item && item.slug) {
|
||||
const entry = await getEntry("docs", item.slug);
|
||||
return entry ? [entryLink(entry, item.label)] : [];
|
||||
}
|
||||
|
||||
if ("items" in item && item.items) {
|
||||
const out: Link[] = [];
|
||||
for (const child of item.items) {
|
||||
out.push(...(await resolveItem(child, docs)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if ("autogenerate" in item && item.autogenerate) {
|
||||
const prefix = item.autogenerate.directory.replace(TRIM_SLASHES, "") + "/";
|
||||
return docs
|
||||
.filter((entry) => entry.id.startsWith(prefix))
|
||||
.toSorted((a, b) => a.id.localeCompare(b.id))
|
||||
.map((entry) => entryLink(entry));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function entryLink(entry: DocsEntry, override?: string): Link {
|
||||
const slug = entry.id === "index" ? "" : entry.id;
|
||||
const href = "/" + slug + (slug ? "/" : "");
|
||||
const label = override ?? entry.data.sidebar?.label ?? entry.data.title;
|
||||
return { label, href };
|
||||
}
|
||||
|
||||
function absUrl(href: string, site: URL | undefined): string {
|
||||
if (ABSOLUTE_URL.test(href)) return href;
|
||||
if (!site) return href;
|
||||
return new URL(href, site).toString();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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.
|
||||
*/
|
||||
const searchDocsTool = {
|
||||
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."),
|
||||
},
|
||||
};
|
||||
|
||||
function buildMcpServer(env: Env): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "emdash-docs",
|
||||
version: "1.0.0",
|
||||
});
|
||||
const aiSearch = (env as { AI_SEARCH?: Env["AI_SEARCH"] }).AI_SEARCH;
|
||||
if (!aiSearch) {
|
||||
server.registerTool("search_docs", searchDocsTool, async () => ({
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Docs search is unavailable in this environment. Configure Cloudflare AI Search in the Workers binding to enable this tool.",
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
server.registerTool("search_docs", searchDocsTool, async ({ query, max_results }) => {
|
||||
const limit = max_results ?? 8;
|
||||
|
||||
const results = await aiSearch.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>;
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*", "./worker-configuration.d.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||