Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
21
docs/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# build output
|
||||
dist/
|
||||
# generated types
|
||||
.astro/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
|
||||
# environment variables
|
||||
.env
|
||||
.env.production
|
||||
|
||||
# macOS-specific files
|
||||
.DS_Store
|
||||
4
docs/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"recommendations": ["astro-build.astro-vscode"],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
11
docs/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"command": "./node_modules/.bin/astro dev",
|
||||
"name": "Development server",
|
||||
"request": "launch",
|
||||
"type": "node-terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
docs/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# EmDash Docs
|
||||
|
||||
Documentation site for EmDash, built with [Starlight](https://starlight.astro.build).
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
175
docs/astro.config.mjs
Normal file
@@ -0,0 +1,175 @@
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import starlight from "@astrojs/starlight";
|
||||
// @ts-check
|
||||
import { defineConfig } from "astro/config";
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://docs.emdashcms.com",
|
||||
integrations: [
|
||||
starlight({
|
||||
title: "EmDash",
|
||||
tagline: "The Astro-native CMS",
|
||||
logo: {
|
||||
light: "./src/assets/logo-light.svg",
|
||||
dark: "./src/assets/logo-dark.svg",
|
||||
replacesTitle: true,
|
||||
},
|
||||
social: [
|
||||
{
|
||||
icon: "github",
|
||||
label: "GitHub",
|
||||
href: "https://github.com/emdash-cms/emdash",
|
||||
},
|
||||
],
|
||||
editLink: {
|
||||
baseUrl: "https://github.com/emdash-cms/emdash/tree/main/docs",
|
||||
},
|
||||
customCss: ["./src/styles/custom.css"],
|
||||
sidebar: [
|
||||
{
|
||||
label: "Start Here",
|
||||
items: [
|
||||
{ label: "Introduction", slug: "introduction" },
|
||||
{ label: "Getting Started", slug: "getting-started" },
|
||||
{ label: "Why EmDash?", slug: "why-emdash" },
|
||||
{ label: "Docs MCP for AI Tools", slug: "docs-mcp" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Coming From...",
|
||||
items: [
|
||||
{
|
||||
label: "EmDash for WordPress Developers",
|
||||
slug: "coming-from/wordpress",
|
||||
},
|
||||
{
|
||||
label: "Astro for WordPress Developers",
|
||||
slug: "coming-from/astro-for-wp-devs",
|
||||
},
|
||||
{
|
||||
label: "EmDash for Astro Developers",
|
||||
slug: "coming-from/astro",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Guides",
|
||||
items: [
|
||||
{ label: "Create a Blog", slug: "guides/create-a-blog" },
|
||||
{
|
||||
label: "Working with Content",
|
||||
slug: "guides/working-with-content",
|
||||
},
|
||||
{ label: "Querying Content", slug: "guides/querying-content" },
|
||||
{ label: "Media Library", slug: "guides/media-library" },
|
||||
{ label: "Taxonomies", slug: "guides/taxonomies" },
|
||||
{ label: "Navigation Menus", slug: "guides/menus" },
|
||||
{ label: "Widget Areas", slug: "guides/widgets" },
|
||||
{ label: "Page Layouts", slug: "guides/page-layouts" },
|
||||
{ label: "Sections", slug: "guides/sections" },
|
||||
{ label: "Site Settings", slug: "guides/site-settings" },
|
||||
{ label: "Authentication", slug: "guides/authentication" },
|
||||
{ label: "Atmosphere Login", slug: "guides/atmosphere-auth" },
|
||||
{ label: "AI Tools", slug: "guides/ai-tools" },
|
||||
{ label: "x402 Payments", slug: "guides/x402-payments" },
|
||||
{ label: "Preview Mode", slug: "guides/preview" },
|
||||
{
|
||||
label: "Internationalization (i18n)",
|
||||
slug: "guides/internationalization",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Plugins",
|
||||
items: [
|
||||
{ label: "Plugin Overview", slug: "plugins/overview" },
|
||||
{ label: "Creating Plugins", slug: "plugins/creating-plugins" },
|
||||
{ label: "Plugin Hooks", slug: "plugins/hooks" },
|
||||
{ label: "Plugin Storage", slug: "plugins/storage" },
|
||||
{ label: "Plugin Settings", slug: "plugins/settings" },
|
||||
{ label: "Admin UI Extensions", slug: "plugins/admin-ui" },
|
||||
{ label: "Block Kit", slug: "plugins/block-kit" },
|
||||
{ label: "Field Kit", slug: "plugins/field-kit" },
|
||||
{ label: "API Routes", slug: "plugins/api-routes" },
|
||||
{ label: "Sandbox & Security", slug: "plugins/sandbox" },
|
||||
{ label: "Publishing Plugins", slug: "plugins/publishing" },
|
||||
{ label: "Installing Plugins", slug: "plugins/installing" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Contributing",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ label: "Contributor Guide", slug: "contributing" },
|
||||
{ label: "Translating EmDash", slug: "contributing/translating" },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
label: "Themes",
|
||||
items: [
|
||||
{ label: "Themes Overview", slug: "themes/overview" },
|
||||
{
|
||||
label: "Creating Themes",
|
||||
slug: "themes/creating-themes",
|
||||
},
|
||||
{ label: "Seed File Format", slug: "themes/seed-files" },
|
||||
{
|
||||
label: "Porting WordPress Themes",
|
||||
slug: "themes/porting-wp-themes",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Migration",
|
||||
items: [
|
||||
{
|
||||
label: "Migrate from WordPress",
|
||||
slug: "migration/from-wordpress",
|
||||
},
|
||||
{ label: "Content Import", slug: "migration/content-import" },
|
||||
{
|
||||
label: "Porting WordPress Plugins",
|
||||
slug: "migration/porting-plugins",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Deployment",
|
||||
items: [
|
||||
{ label: "Deploy to Cloudflare", slug: "deployment/cloudflare" },
|
||||
{ label: "Deploy to Node.js", slug: "deployment/nodejs" },
|
||||
{ label: "Database Options", slug: "deployment/database" },
|
||||
{ label: "Storage Options", slug: "deployment/storage" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Concepts",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ label: "Architecture", slug: "concepts/architecture" },
|
||||
{ label: "Collections", slug: "concepts/collections" },
|
||||
{ label: "Content Model", slug: "concepts/content-model" },
|
||||
{ label: "The Admin Panel", slug: "concepts/admin-panel" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
collapsed: true,
|
||||
items: [
|
||||
{ label: "Configuration", slug: "reference/configuration" },
|
||||
{ label: "CLI Commands", slug: "reference/cli" },
|
||||
{ label: "API Reference", slug: "reference/api" },
|
||||
{ label: "Field Types", slug: "reference/field-types" },
|
||||
{ label: "Hook Reference", slug: "reference/hooks" },
|
||||
{ label: "REST API", slug: "reference/rest-api" },
|
||||
{ label: "MCP Server", slug: "reference/mcp-server" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
|
||||
adapter: cloudflare(),
|
||||
});
|
||||
30
docs/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"generate-types": "wrangler types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "^13.1.7",
|
||||
"@astrojs/starlight": "^0.38.2",
|
||||
"@astrojs/starlight-tailwind": "^5.0.0",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"agents": "^0.12.0",
|
||||
"astro": "^6.1.3",
|
||||
"sharp": "^0.34.5",
|
||||
"starlight-utils": "^1.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"wrangler": "catalog:",
|
||||
"zod": "^4.4.1"
|
||||
},
|
||||
"overrides": {
|
||||
"vite": "^7"
|
||||
}
|
||||
}
|
||||
12
docs/public/favicon.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="3" y="3" width="69" height="69" rx="10.518" stroke="url(#fav-border)" stroke-width="6"/>
|
||||
<rect x="18" y="34" width="39.3661" height="6.56101" fill="url(#fav-dash)"/>
|
||||
<defs>
|
||||
<linearGradient id="fav-border" x1="-42.9996" y1="124" x2="92.4233" y2="-41.7456" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="fav-dash" x1="91.4992" y1="27.4982" x2="28.1217" y2="54.1775" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
4
docs/public/robots.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://docs.emdashcms.com/sitemap-index.xml
|
||||
BIN
docs/src/assets/houston.webp
Normal file
|
After Width: | Height: | Size: 96 KiB |
18
docs/src/assets/logo-dark.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="471" height="118" viewBox="0 0 471 118" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.410156 96.5125V21.2097C0.410156 9.48841 9.91245 -0.013916 21.6338 -0.013916V9.40601L21.3291 9.40991C14.9509 9.57133 9.83008 14.7927 9.83008 21.2097V96.5125C9.83008 102.93 14.9509 108.151 21.3291 108.312L21.6338 108.316H96.9365L97.2412 108.312C103.518 108.153 108.577 103.094 108.736 96.8171L108.74 96.5125V21.2097C108.74 14.6909 103.455 9.40601 96.9365 9.40601V-0.013916C108.658 -0.013916 118.16 9.48838 118.16 21.2097V96.5125C118.16 108.234 108.658 117.736 96.9365 117.736H21.6338C9.91248 117.736 0.410156 108.234 0.410156 96.5125ZM96.9365 -0.013916V9.40601H21.6338V-0.013916H96.9365Z" fill="url(#ld-icon)"/>
|
||||
<path d="M28.6699 53.366H90.4746V63.6668H28.6699V53.366Z" fill="url(#ld-dash)"/>
|
||||
<path d="M154.762 90V27.4834H194.447V35.8449H164.467V54.0844H192.844V62.2293H164.467V81.6385H194.447V90H154.762Z" fill="white"/>
|
||||
<path d="M204.172 90V44.4231H213.53V51.4849H213.747C215.697 46.7193 220.332 43.5566 226.311 43.5566C232.593 43.5566 237.185 46.8059 239.005 52.5247H239.222C241.561 46.9792 246.933 43.5566 253.432 43.5566C262.443 43.5566 268.335 49.5353 268.335 58.6767V90H258.934V60.9296C258.934 54.9942 255.771 51.5716 250.226 51.5716C244.68 51.5716 240.825 55.7307 240.825 61.4928V90H231.64V60.2364C231.64 54.9508 228.304 51.5716 223.018 51.5716C217.473 51.5716 213.53 55.9473 213.53 61.8394V90H204.172Z" fill="white"/>
|
||||
<path d="M279.404 90V27.4834H301.456C319.998 27.4834 331.046 38.8776 331.046 58.5467V58.6334C331.046 78.3892 320.085 90 301.456 90H279.404ZM289.108 81.5951H300.546C313.803 81.5951 321.125 73.4935 321.125 58.72V58.6334C321.125 43.9465 313.716 35.8449 300.546 35.8449H289.108V81.5951Z" fill="white"/>
|
||||
<path d="M353.379 90.8232C344.281 90.8232 338.172 85.2344 338.172 77.0461V76.9595C338.172 69.0312 344.324 64.1789 355.112 63.529L367.502 62.7925V59.3699C367.502 54.3443 364.253 51.3116 358.448 51.3116C353.032 51.3116 349.696 53.8677 348.916 57.507L348.83 57.8969H339.992L340.035 57.4203C340.685 49.5787 347.487 43.5566 358.708 43.5566C369.842 43.5566 376.904 49.4487 376.904 58.5901V90H367.502V82.8082H367.329C364.686 87.7038 359.401 90.8232 353.379 90.8232ZM347.617 76.8295C347.617 80.8153 350.909 83.3281 355.935 83.3281C362.52 83.3281 367.502 78.8657 367.502 72.9303V69.3778L356.368 70.0709C350.736 70.4175 347.617 72.887 347.617 76.7428V76.8295Z" fill="white"/>
|
||||
<path d="M403.959 90.9098C392.564 90.9098 385.893 85.2777 384.939 76.9595L384.896 76.5695H394.167L394.254 77.0028C395.121 81.2052 398.24 83.6747 404.002 83.6747C409.634 83.6747 413.013 81.3352 413.013 77.6527V77.6093C413.013 74.6633 411.367 72.9737 406.471 71.8039L399.02 70.1143C390.355 68.1214 386.066 63.9623 386.066 57.3337V57.2903C386.066 49.1454 393.171 43.5566 403.655 43.5566C414.443 43.5566 420.942 49.5787 421.418 57.3337L421.462 57.8536H412.667L412.624 57.5503C412.06 53.5645 408.941 50.7917 403.655 50.7917C398.63 50.7917 395.467 53.1746 395.467 56.8138V56.8571C395.467 59.6732 397.33 61.5794 402.226 62.7492L409.634 64.4388C418.949 66.605 422.501 70.2876 422.501 76.8295V76.8728C422.501 85.191 414.703 90.9098 403.959 90.9098Z" fill="white"/>
|
||||
<path d="M431.014 90V27.4834H440.372V51.9182H440.588C443.014 46.6326 447.91 43.5566 454.712 43.5566C464.46 43.5566 470.872 50.8351 470.872 61.8394V90H461.514V63.6157C461.514 56.0773 457.701 51.5716 451.116 51.5716C444.661 51.5716 440.372 56.5105 440.372 63.6157V90H431.014Z" fill="white"/>
|
||||
<defs>
|
||||
<linearGradient id="ld-icon" x1="-67.1002" y1="194.666" x2="145.514" y2="-65.5554" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ld-dash" x1="144.064" y1="43.1581" x2="44.5609" y2="85.0447" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
18
docs/src/assets/logo-light.svg
Normal file
@@ -0,0 +1,18 @@
|
||||
<svg width="471" height="118" viewBox="0 0 471 118" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.410156 96.5125V21.2097C0.410156 9.48841 9.91245 -0.013916 21.6338 -0.013916V9.40601L21.3291 9.40991C14.9509 9.57133 9.83008 14.7927 9.83008 21.2097V96.5125C9.83008 102.93 14.9509 108.151 21.3291 108.312L21.6338 108.316H96.9365L97.2412 108.312C103.518 108.153 108.577 103.094 108.736 96.8171L108.74 96.5125V21.2097C108.74 14.6909 103.455 9.40601 96.9365 9.40601V-0.013916C108.658 -0.013916 118.16 9.48838 118.16 21.2097V96.5125C118.16 108.234 108.658 117.736 96.9365 117.736H21.6338C9.91248 117.736 0.410156 108.234 0.410156 96.5125ZM96.9365 -0.013916V9.40601H21.6338V-0.013916H96.9365Z" fill="url(#ll-icon)"/>
|
||||
<path d="M28.6699 53.366H90.4746V63.6668H28.6699V53.366Z" fill="url(#ll-dash)"/>
|
||||
<path d="M154.762 90V27.4834H194.447V35.8449H164.467V54.0844H192.844V62.2293H164.467V81.6385H194.447V90H154.762Z" fill="#0B0152"/>
|
||||
<path d="M204.172 90V44.4231H213.53V51.4849H213.747C215.697 46.7193 220.332 43.5566 226.311 43.5566C232.593 43.5566 237.185 46.8059 239.005 52.5247H239.222C241.561 46.9792 246.933 43.5566 253.432 43.5566C262.443 43.5566 268.335 49.5353 268.335 58.6767V90H258.934V60.9296C258.934 54.9942 255.771 51.5716 250.226 51.5716C244.68 51.5716 240.825 55.7307 240.825 61.4928V90H231.64V60.2364C231.64 54.9508 228.304 51.5716 223.018 51.5716C217.473 51.5716 213.53 55.9473 213.53 61.8394V90H204.172Z" fill="#0B0152"/>
|
||||
<path d="M279.404 90V27.4834H301.456C319.998 27.4834 331.046 38.8776 331.046 58.5467V58.6334C331.046 78.3892 320.085 90 301.456 90H279.404ZM289.108 81.5951H300.546C313.803 81.5951 321.125 73.4935 321.125 58.72V58.6334C321.125 43.9465 313.716 35.8449 300.546 35.8449H289.108V81.5951Z" fill="#0B0152"/>
|
||||
<path d="M353.379 90.8232C344.281 90.8232 338.172 85.2344 338.172 77.0461V76.9595C338.172 69.0312 344.324 64.1789 355.112 63.529L367.502 62.7925V59.3699C367.502 54.3443 364.253 51.3116 358.448 51.3116C353.032 51.3116 349.696 53.8677 348.916 57.507L348.83 57.8969H339.992L340.035 57.4203C340.685 49.5787 347.487 43.5566 358.708 43.5566C369.842 43.5566 376.904 49.4487 376.904 58.5901V90H367.502V82.8082H367.329C364.686 87.7038 359.401 90.8232 353.379 90.8232ZM347.617 76.8295C347.617 80.8153 350.909 83.3281 355.935 83.3281C362.52 83.3281 367.502 78.8657 367.502 72.9303V69.3778L356.368 70.0709C350.736 70.4175 347.617 72.887 347.617 76.7428V76.8295Z" fill="#0B0152"/>
|
||||
<path d="M403.959 90.9098C392.564 90.9098 385.893 85.2777 384.939 76.9595L384.896 76.5695H394.167L394.254 77.0028C395.121 81.2052 398.24 83.6747 404.002 83.6747C409.634 83.6747 413.013 81.3352 413.013 77.6527V77.6093C413.013 74.6633 411.367 72.9737 406.471 71.8039L399.02 70.1143C390.355 68.1214 386.066 63.9623 386.066 57.3337V57.2903C386.066 49.1454 393.171 43.5566 403.655 43.5566C414.443 43.5566 420.942 49.5787 421.418 57.3337L421.462 57.8536H412.667L412.624 57.5503C412.06 53.5645 408.941 50.7917 403.655 50.7917C398.63 50.7917 395.467 53.1746 395.467 56.8138V56.8571C395.467 59.6732 397.33 61.5794 402.226 62.7492L409.634 64.4388C418.949 66.605 422.501 70.2876 422.501 76.8295V76.8728C422.501 85.191 414.703 90.9098 403.959 90.9098Z" fill="#0B0152"/>
|
||||
<path d="M431.014 90V27.4834H440.372V51.9182H440.588C443.014 46.6326 447.91 43.5566 454.712 43.5566C464.46 43.5566 470.872 50.8351 470.872 61.8394V90H461.514V63.6157C461.514 56.0773 457.701 51.5716 451.116 51.5716C444.661 51.5716 440.372 56.5105 440.372 63.6157V90H431.014Z" fill="#0B0152"/>
|
||||
<defs>
|
||||
<linearGradient id="ll-icon" x1="-67.1002" y1="194.666" x2="145.514" y2="-65.5554" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F006B"/><stop offset="0.0833" stop-color="#281A81"/><stop offset="0.1667" stop-color="#5D0C83"/><stop offset="0.25" stop-color="#911475"/><stop offset="0.3333" stop-color="#CE2F55"/><stop offset="0.4167" stop-color="#FF6633"/><stop offset="0.5" stop-color="#F6821F"/><stop offset="0.5833" stop-color="#FBAD41"/><stop offset="0.6667" stop-color="#FFCD89"/><stop offset="0.75" stop-color="#FFE9CB"/><stop offset="0.8333" stop-color="#FFF7EC"/><stop offset="0.9167" stop-color="#FFF8EE"/><stop offset="1" stop-color="white"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="ll-dash" x1="144.064" y1="43.1581" x2="44.5609" y2="85.0447" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/><stop offset="0.1293" stop-color="#FFF8EE"/><stop offset="0.6171" stop-color="#FBAD41"/><stop offset="0.848" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
BIN
docs/src/assets/screenshots/admin-content-types.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/src/assets/screenshots/admin-dashboard.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/src/assets/screenshots/admin-media-library.png
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
docs/src/assets/screenshots/admin-post-editor.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/src/assets/screenshots/admin-posts-list.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
7
docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { docsLoader } from "@astrojs/starlight/loaders";
|
||||
import { docsSchema } from "@astrojs/starlight/schema";
|
||||
import { defineCollection } from "astro:content";
|
||||
|
||||
export const collections = {
|
||||
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||
};
|
||||
541
docs/src/content/docs/coming-from/astro-for-wp-devs.mdx
Normal file
@@ -0,0 +1,541 @@
|
||||
---
|
||||
title: Astro for WordPress Developers
|
||||
description: Learn Astro fundamentals through the lens of WordPress concepts you already know
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, TabItem, Tabs } from "@astrojs/starlight/components";
|
||||
|
||||
Astro is a web framework for building content-focused websites. When using EmDash, Astro replaces your WordPress theme—it handles templating, routing, and rendering.
|
||||
|
||||
This guide teaches Astro fundamentals by mapping them to WordPress concepts you already understand.
|
||||
|
||||
## Key Paradigm Shifts
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Server-rendered by default" icon="laptop">
|
||||
Like PHP, Astro code runs on the server. Unlike PHP, it outputs static HTML by default with zero
|
||||
JavaScript.
|
||||
</Card>
|
||||
<Card title="Zero JS unless you add it" icon="rocket">
|
||||
WordPress loads jQuery and theme scripts automatically. Astro ships nothing to the browser
|
||||
unless you explicitly add it.
|
||||
</Card>
|
||||
<Card title="Component-based architecture" icon="puzzle">
|
||||
Instead of scattered template tags and includes, build with composable, self-contained
|
||||
components.
|
||||
</Card>
|
||||
<Card title="File-based routing" icon="document">
|
||||
No rewrite rules or `query_vars`. The file structure in `src/pages/` defines your URLs directly.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Project Structure
|
||||
|
||||
WordPress themes have a flat structure with magic filenames. Astro uses explicit directories:
|
||||
|
||||
| WordPress | Astro | Purpose |
|
||||
| --------------------------- | ------------------ | ------------------ |
|
||||
| `index.php`, `single.php` | `src/pages/` | Routes (URLs) |
|
||||
| `template-parts/` | `src/components/` | Reusable UI pieces |
|
||||
| `header.php` + `footer.php` | `src/layouts/` | Page wrappers |
|
||||
| `style.css` | `src/styles/` | Global CSS |
|
||||
| `functions.php` | `astro.config.mjs` | Site configuration |
|
||||
|
||||
A typical Astro project:
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI (Header, PostCard, etc.)
|
||||
├── layouts/ # Page shells (Base.astro)
|
||||
├── pages/ # Routes - files become URLs
|
||||
│ ├── index.astro # → /
|
||||
│ ├── posts/
|
||||
│ │ ├── index.astro # → /posts
|
||||
│ │ └── [slug].astro # → /posts/hello-world
|
||||
│ └── [slug].astro # → /about, /contact, etc.
|
||||
└── styles/
|
||||
└── global.css
|
||||
```
|
||||
|
||||
## Astro Components
|
||||
|
||||
`.astro` files are Astro's equivalent of PHP templates. Each file has two parts:
|
||||
|
||||
1. **Frontmatter** (between `---` fences) — Server-side code, like PHP at the top of a template
|
||||
2. **Template** — HTML with expressions, like the rest of a PHP template
|
||||
|
||||
```astro title="src/components/PostCard.astro"
|
||||
---
|
||||
// Frontmatter: runs on server, never sent to browser
|
||||
interface Props {
|
||||
title: string;
|
||||
excerpt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const { title, excerpt, url } = Astro.props;
|
||||
---
|
||||
<!-- Template: outputs HTML -->
|
||||
<article class="post-card">
|
||||
<h2><a href={url}>{title}</a></h2>
|
||||
<p>{excerpt}</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
Key differences from PHP:
|
||||
|
||||
- **Frontmatter is isolated.** Variables declared there are available in the template, but the code itself never reaches the browser.
|
||||
- **Imports go in frontmatter.** Components, data, utilities—all imported at the top.
|
||||
- **TypeScript works.** Define prop types with `interface Props` for editor autocomplete and validation.
|
||||
|
||||
## Template Expressions
|
||||
|
||||
Astro templates use `{curly braces}` instead of `<?php ?>` tags. The syntax is JSX-like but outputs pure HTML.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Astro">
|
||||
|
||||
```astro title="src/components/PostList.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
const showTitle = true;
|
||||
---
|
||||
{showTitle && <h1>Latest Posts</h1>}
|
||||
|
||||
{posts.length > 0 ? (
|
||||
<ul>
|
||||
{posts.map(post => (
|
||||
<li>
|
||||
<a href={`/posts/${post.id}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>No posts found.</p>
|
||||
)}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="PHP">
|
||||
|
||||
```php title="template-parts/post-list.php"
|
||||
<?php
|
||||
$posts = new WP_Query(['post_type' => 'post']);
|
||||
$show_title = true;
|
||||
?>
|
||||
|
||||
<?php if ($show_title): ?>
|
||||
<h1>Latest Posts</h1>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($posts->have_posts()): ?>
|
||||
<ul>
|
||||
<?php while ($posts->have_posts()): $posts->the_post(); ?>
|
||||
<li>
|
||||
<a href="<?php the_permalink(); ?>"><?php the_title(); ?></a>
|
||||
</li>
|
||||
<?php endwhile; wp_reset_postdata(); ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p>No posts found.</p>
|
||||
<?php endif; ?>
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Expression Patterns
|
||||
|
||||
| Pattern | Purpose |
|
||||
| -------------------------------------- | --------------------- |
|
||||
| `{variable}` | Output a value |
|
||||
| `{condition && <Element />}` | Conditional rendering |
|
||||
| `{condition ? <A /> : <B />}` | If/else |
|
||||
| `{items.map(item => <Li>{item}</Li>)}` | Loops |
|
||||
|
||||
<Aside>
|
||||
Unlike PHP, you don't need to escape output. Astro escapes expressions by default, preventing XSS
|
||||
vulnerabilities.
|
||||
</Aside>
|
||||
|
||||
## Props and Slots
|
||||
|
||||
Components receive data through **props** (like function arguments) and **slots** (like `do_action` insertion points).
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Astro">
|
||||
|
||||
```astro title="src/components/Card.astro"
|
||||
---
|
||||
interface Props {
|
||||
title: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
const { title, featured = false } = Astro.props;
|
||||
---
|
||||
<article class:list={["card", { featured }]}>
|
||||
<h2>{title}</h2>
|
||||
<slot />
|
||||
<slot name="footer" />
|
||||
</article>
|
||||
```
|
||||
|
||||
Usage:
|
||||
|
||||
```astro
|
||||
<Card title="Hello" featured>
|
||||
<p>This goes in the default slot.</p>
|
||||
<footer slot="footer">Footer content</footer>
|
||||
</Card>
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="PHP">
|
||||
|
||||
```php title="template-parts/card.php"
|
||||
<?php
|
||||
// Usage: get_template_part('template-parts/card', null, [
|
||||
// 'title' => 'Hello',
|
||||
// 'featured' => true
|
||||
// ]);
|
||||
|
||||
$title = $args['title'] ?? '';
|
||||
$featured = $args['featured'] ?? false;
|
||||
$class = $featured ? 'card featured' : 'card';
|
||||
?>
|
||||
<article class="<?php echo esc_attr($class); ?>">
|
||||
<h2><?php echo esc_html($title); ?></h2>
|
||||
<?php
|
||||
// No direct equivalent to slots.
|
||||
// WordPress uses do_action() for similar patterns:
|
||||
do_action('card_content');
|
||||
do_action('card_footer');
|
||||
?>
|
||||
</article>
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Props vs `$args`
|
||||
|
||||
In WordPress, `get_template_part()` passes data via the `$args` array. Astro props are typed and destructured:
|
||||
|
||||
```astro
|
||||
---
|
||||
// Type-safe with defaults
|
||||
interface Props {
|
||||
title: string;
|
||||
count?: number;
|
||||
}
|
||||
const { title, count = 10 } = Astro.props;
|
||||
---
|
||||
```
|
||||
|
||||
### Slots vs Hooks
|
||||
|
||||
WordPress uses `do_action()` to create insertion points. Astro uses slots:
|
||||
|
||||
| WordPress | Astro |
|
||||
| ----------------------------- | ------------------------ |
|
||||
| `do_action('before_content')` | `<slot name="before" />` |
|
||||
| Default content area | `<slot />` |
|
||||
| `do_action('after_content')` | `<slot name="after" />` |
|
||||
|
||||
The difference: slots receive child elements at the call site, while WordPress hooks require separate `add_action()` calls elsewhere.
|
||||
|
||||
## Layouts
|
||||
|
||||
Layouts wrap pages with common HTML structure—the `<head>`, header, footer, and anything shared across pages. This replaces `header.php` + `footer.php`.
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import "../styles/global.css";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const { title, description = "My EmDash Site" } = Astro.props;
|
||||
---
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content={description} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<nav><!-- Navigation --></nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© {new Date().getFullYear()}</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Use the layout in a page:
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
<Base title="Home">
|
||||
<h1>Welcome</h1>
|
||||
<p>Page content goes in the slot.</p>
|
||||
</Base>
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Unlike `get_header()` and `get_footer()`, layouts keep the entire HTML structure in one file. This
|
||||
makes it easier to see the full page structure and pass data between sections.
|
||||
</Aside>
|
||||
|
||||
## Styling
|
||||
|
||||
Astro offers several styling approaches. The most distinctive is **scoped styles**.
|
||||
|
||||
### Scoped Styles
|
||||
|
||||
Styles in a `<style>` tag are automatically scoped to that component:
|
||||
|
||||
```astro title="src/components/Card.astro"
|
||||
<article class="card">
|
||||
<h2>Title</h2>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
/* Only affects .card in THIS component */
|
||||
.card {
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: navy;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
The generated HTML includes unique class names to prevent style leakage. No more specificity wars.
|
||||
|
||||
### Global Styles
|
||||
|
||||
For site-wide styles, create a CSS file and import it in a layout:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import "../styles/global.css";
|
||||
---
|
||||
```
|
||||
|
||||
### Conditional Classes
|
||||
|
||||
The `class:list` directive replaces manual class string building:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Astro">
|
||||
|
||||
```astro
|
||||
---
|
||||
const { featured, size = "medium" } = Astro.props;
|
||||
---
|
||||
<article class:list={[
|
||||
"card",
|
||||
size,
|
||||
{ featured, "has-border": true }
|
||||
]}>
|
||||
```
|
||||
|
||||
Output: `<article class="card medium featured has-border">`
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="PHP">
|
||||
|
||||
```php
|
||||
<?php
|
||||
$classes = ['card', $size];
|
||||
if ($featured) $classes[] = 'featured';
|
||||
if (true) $classes[] = 'has-border';
|
||||
?>
|
||||
<article class="<?php echo esc_attr(implode(' ', $classes)); ?>">
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Client-Side JavaScript
|
||||
|
||||
Astro ships zero JavaScript by default. This is the biggest mental shift from WordPress.
|
||||
|
||||
### Adding Interactivity
|
||||
|
||||
For simple interactions, add a `<script>` tag:
|
||||
|
||||
```astro title="src/components/MobileMenu.astro"
|
||||
<button id="menu-toggle">Menu</button>
|
||||
<nav id="mobile-menu" hidden>
|
||||
<slot />
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const toggle = document.getElementById("menu-toggle");
|
||||
const menu = document.getElementById("mobile-menu");
|
||||
|
||||
toggle?.addEventListener("click", () => {
|
||||
menu?.toggleAttribute("hidden");
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
Scripts are bundled and deduplicated automatically. If this component appears twice on a page, the script runs once.
|
||||
|
||||
### Advanced: Interactive Components
|
||||
|
||||
For more complex interactivity, Astro can load JavaScript components (React, Vue, Svelte) on demand. This is optional—most sites work fine with just `<script>` tags.
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import SearchWidget from "../components/SearchWidget.jsx";
|
||||
---
|
||||
<!-- Only load JavaScript when the search box scrolls into view -->
|
||||
<SearchWidget client:visible />
|
||||
```
|
||||
|
||||
| Directive | When JavaScript loads |
|
||||
| ---------------- | ------------------------------ |
|
||||
| `client:load` | Immediately on page load |
|
||||
| `client:visible` | When component enters viewport |
|
||||
| `client:idle` | When browser is idle |
|
||||
|
||||
<Aside type="tip">
|
||||
This is entirely optional. You can build full EmDash sites without touching React, Vue, or any JavaScript framework. The `<script>` tag approach handles most interactive needs.
|
||||
</Aside>
|
||||
|
||||
## Routing
|
||||
|
||||
Astro uses **file-based routing**. Files in `src/pages/` become URLs:
|
||||
|
||||
| File | URL |
|
||||
| ------------------------------ | -------------------- |
|
||||
| `src/pages/index.astro` | `/` |
|
||||
| `src/pages/about.astro` | `/about` |
|
||||
| `src/pages/posts/index.astro` | `/posts` |
|
||||
| `src/pages/posts/[slug].astro` | `/posts/hello-world` |
|
||||
| `src/pages/[...slug].astro` | Any path (catch-all) |
|
||||
|
||||
### Dynamic Routes
|
||||
|
||||
For CMS content, use bracket syntax for dynamic segments:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
// For static builds, define which pages to generate
|
||||
export async function getStaticPaths() {
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
return posts.map(post => ({
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
<Base title={post.data.title}>
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Compared to WordPress
|
||||
|
||||
| WordPress | Astro |
|
||||
| -------------------------------------- | ----------------------------------- |
|
||||
| Template hierarchy (`single-post.php`) | Explicit file: `posts/[slug].astro` |
|
||||
| Rewrite rules + `query_vars` | File structure |
|
||||
| `$wp_query` determines template | URL maps directly to file |
|
||||
| `add_rewrite_rule()` | Create files or folders |
|
||||
|
||||
<Aside type="tip">
|
||||
No more guessing which template WordPress will use. The URL `/posts/hello` always loads
|
||||
`src/pages/posts/[slug].astro`.
|
||||
</Aside>
|
||||
|
||||
## Where WordPress Concepts Live
|
||||
|
||||
A reference for finding the Astro/EmDash equivalent of WordPress features:
|
||||
|
||||
### Templating
|
||||
|
||||
| WordPress | Astro/EmDash |
|
||||
| ------------------------ | ---------------------------------- |
|
||||
| Template hierarchy | File-based routing in `src/pages/` |
|
||||
| `get_template_part()` | Import and use components |
|
||||
| `the_content()` | `<PortableText value={content} />` |
|
||||
| `the_title()`, `the_*()` | Access via `post.data.title` |
|
||||
| Template tags | Template expressions `{value}` |
|
||||
| `body_class()` | `class:list` directive |
|
||||
|
||||
### Data and Queries
|
||||
|
||||
| WordPress | Astro/EmDash |
|
||||
| ----------------- | -------------------------------------- |
|
||||
| `WP_Query` | `getEmDashCollection(type, filters)` |
|
||||
| `get_post()` | `getEmDashEntry(type, id)` |
|
||||
| `get_posts()` | `getEmDashCollection(type)` |
|
||||
| `get_the_terms()` | Access via `entry.data.categories` |
|
||||
| `get_post_meta()` | Access via `entry.data.fieldName` |
|
||||
| `get_option()` | `getSiteSettings()` |
|
||||
| `wp_nav_menu()` | `getMenu(location)` |
|
||||
|
||||
### Extensibility
|
||||
|
||||
| WordPress | Astro/EmDash |
|
||||
| ----------------------- | ------------------------------------- |
|
||||
| `add_action()` | EmDash hooks, Astro middleware |
|
||||
| `add_filter()` | EmDash hooks |
|
||||
| `add_shortcode()` | Portable Text custom blocks |
|
||||
| `register_block_type()` | Portable Text custom blocks |
|
||||
| `register_sidebar()` | EmDash widget areas |
|
||||
| Plugins | Astro integrations + EmDash plugins |
|
||||
|
||||
### Content Types
|
||||
|
||||
| WordPress | Astro/EmDash |
|
||||
| ---------------------- | ------------------------------------- |
|
||||
| `register_post_type()` | Create collection in admin UI |
|
||||
| `register_taxonomy()` | Create taxonomy in admin UI |
|
||||
| `register_meta()` | Add field to collection schema |
|
||||
| Post status | Entry status (draft, published, etc.) |
|
||||
| Featured image | Media reference field |
|
||||
| Gutenberg blocks | Portable Text blocks |
|
||||
|
||||
## Summary
|
||||
|
||||
The jump from WordPress to Astro is significant but logical:
|
||||
|
||||
1. **PHP templates → Astro components** — Same idea (server code + HTML), better organization
|
||||
2. **Template tags → Props and imports** — Explicit data flow instead of globals
|
||||
3. **Theme files → Pages directory** — URLs match file structure
|
||||
4. **Hooks → Slots and middleware** — More predictable insertion points
|
||||
5. **jQuery by default → Zero JS by default** — Add interactivity intentionally
|
||||
|
||||
Start with the [Getting Started](/getting-started/) guide to build your first EmDash site, or explore [Working with Content](/guides/working-with-content/) to learn how to query and render CMS data.
|
||||
387
docs/src/content/docs/coming-from/astro.mdx
Normal file
@@ -0,0 +1,387 @@
|
||||
---
|
||||
title: EmDash for Astro Developers
|
||||
description: Add WordPress-style CMS features to your Astro site with EmDash
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is a CMS built specifically for Astro—not a generic headless CMS with an Astro adapter. It extends your Astro site with database-backed content, a polished admin UI, and WordPress-style features (menus, widgets, taxonomies) while preserving the developer experience you expect.
|
||||
|
||||
Everything you know about Astro still applies. EmDash enhances your site; it doesn't replace your workflow.
|
||||
|
||||
## What EmDash Adds
|
||||
|
||||
EmDash provides the content management features that file-based Astro sites lack:
|
||||
|
||||
| Feature | Description |
|
||||
| -------------------- | ---------------------------------------------------- |
|
||||
| **Admin UI** | Full WYSIWYG editing interface at `/_emdash/admin` |
|
||||
| **Database storage** | Content stored in SQLite, libSQL, or Cloudflare D1 |
|
||||
| **Media library** | Upload, organize, and serve images and files |
|
||||
| **Navigation menus** | Drag-and-drop menu management with nesting |
|
||||
| **Widget areas** | Dynamic sidebars and footer regions |
|
||||
| **Site settings** | Global configuration (title, logo, social links) |
|
||||
| **Taxonomies** | Categories, tags, and custom taxonomies |
|
||||
| **Preview system** | Signed preview URLs for draft content |
|
||||
| **Revisions** | Content version history |
|
||||
|
||||
<Aside type="tip">
|
||||
Content changes appear immediately without rebuilding your site. EmDash sites run in SSR mode by
|
||||
default.
|
||||
</Aside>
|
||||
|
||||
## Astro Collections vs EmDash
|
||||
|
||||
Astro's `astro:content` collections are file-based and resolved at build time. EmDash collections are database-backed and resolved at runtime.
|
||||
|
||||
| | Astro Collections | EmDash Collections |
|
||||
| ------------------ | ------------------------------------ | ------------------------------------ |
|
||||
| **Storage** | Markdown/MDX files in `src/content/` | SQLite/D1 database |
|
||||
| **Editing** | Code editor | Admin UI |
|
||||
| **Content format** | Markdown with frontmatter | Portable Text (structured JSON) |
|
||||
| **Updates** | Requires rebuild | Instant (SSR) |
|
||||
| **Schema** | Zod in `content.config.ts` | Defined in admin, stored in database |
|
||||
| **Best for** | Developer-managed content | Editor-managed content |
|
||||
|
||||
### Use Both Together
|
||||
|
||||
Astro collections and EmDash can coexist. Use Astro collections for developer content (docs, changelogs) and EmDash for editor content (blog posts, pages):
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import { getCollection } from "astro:content";
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
// Developer-managed docs from files
|
||||
const docs = await getCollection("docs");
|
||||
|
||||
// Editor-managed posts from database
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
limit: 5,
|
||||
});
|
||||
---
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
EmDash requires two configuration files.
|
||||
|
||||
### Astro Integration
|
||||
|
||||
```ts title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server", // Required for EmDash
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Live Collections Loader
|
||||
|
||||
```ts title="src/live.config.ts"
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({
|
||||
loader: emdashLoader(),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
This registers EmDash as a live content source. The `_emdash` collection internally routes to your content types (posts, pages, products).
|
||||
|
||||
## Querying Content
|
||||
|
||||
EmDash provides query functions that follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning `{ entries, error }` or `{ entry, error }`:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="EmDash">
|
||||
```ts
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
// Get all published posts - returns { entries, error }
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Get a single post by slug - returns { entry, error, isPreview }
|
||||
const { entry: post } = await getEmDashEntry("posts", "my-post");
|
||||
|
||||
````
|
||||
</TabItem>
|
||||
<TabItem label="Astro">
|
||||
```ts
|
||||
import { getCollection, getEntry } from "astro:content";
|
||||
|
||||
// Get all blog entries
|
||||
const posts = await getCollection("blog");
|
||||
|
||||
// Get a single entry by slug
|
||||
const post = await getEntry("blog", "my-post");
|
||||
````
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Filtering Options
|
||||
|
||||
`getEmDashCollection` supports filtering that Astro's `getCollection` doesn't:
|
||||
|
||||
```ts
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published", // draft | published | archived
|
||||
limit: 10, // max results
|
||||
where: { category: "news" }, // taxonomy filter
|
||||
});
|
||||
```
|
||||
|
||||
## Rendering Content
|
||||
|
||||
EmDash stores rich text as Portable Text, a structured JSON format. Render it with the `PortableText` component:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Astro">
|
||||
```astro title="src/pages/blog/[slug].astro"
|
||||
---
|
||||
import { getEntry, render } from "astro:content";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const post = await getEntry("blog", slug);
|
||||
const { Content } = await render(post);
|
||||
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<Content />
|
||||
</article>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside>
|
||||
Portable Text preserves content structure without embedding HTML. This makes content portable
|
||||
across renderers and prevents XSS vulnerabilities.
|
||||
</Aside>
|
||||
|
||||
## Dynamic Features
|
||||
|
||||
EmDash provides APIs for WordPress-style features that don't exist in Astro's content layer.
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
const primaryMenu = await getMenu("primary");
|
||||
---
|
||||
|
||||
{primaryMenu && (
|
||||
<nav>
|
||||
<ul>
|
||||
{primaryMenu.items.map(item => (
|
||||
<li>
|
||||
<a href={item.url}>{item.label}</a>
|
||||
{item.children.length > 0 && (
|
||||
<ul>
|
||||
{item.children.map(child => (
|
||||
<li><a href={child.url}>{child.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
```
|
||||
|
||||
### Widget Areas
|
||||
|
||||
```astro title="src/layouts/BlogPost.astro"
|
||||
---
|
||||
import { getWidgetArea } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
---
|
||||
|
||||
{sidebar && sidebar.widgets.length > 0 && (
|
||||
<aside>
|
||||
{sidebar.widgets.map(widget => (
|
||||
<div class="widget">
|
||||
{widget.title && <h3>{widget.title}</h3>}
|
||||
{widget.type === "content" && widget.content && (
|
||||
<PortableText value={widget.content} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
```
|
||||
|
||||
### Site Settings
|
||||
|
||||
```astro title="src/components/Header.astro"
|
||||
---
|
||||
import { getSiteSettings, getSiteSetting } from "emdash";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
// Or fetch individual values:
|
||||
const title = await getSiteSetting("title");
|
||||
---
|
||||
|
||||
<header>
|
||||
{settings.logo ? (
|
||||
<img src={settings.logo.url} alt={settings.title} />
|
||||
) : (
|
||||
<span>{settings.title}</span>
|
||||
)}
|
||||
{settings.tagline && <p>{settings.tagline}</p>}
|
||||
</header>
|
||||
```
|
||||
|
||||
## Plugins
|
||||
|
||||
Extend EmDash with plugins that add hooks, storage, settings, and admin UI:
|
||||
|
||||
```ts title="astro.config.mjs"
|
||||
import emdash from "emdash/astro";
|
||||
import seoPlugin from "@emdash-cms/plugin-seo";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
// ...
|
||||
plugins: [seoPlugin({ generateSitemap: true })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Create custom plugins with `definePlugin`:
|
||||
|
||||
```ts title="src/plugins/analytics.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "analytics",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:read"],
|
||||
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Content saved", { id: event.content.id });
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
trackingId: { type: "string", label: "Tracking ID" },
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Server Rendering
|
||||
|
||||
EmDash sites run in SSR mode. Content changes appear immediately without rebuilds.
|
||||
|
||||
For static pages with `getStaticPaths`, content is fetched at build time:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.data.slug },
|
||||
}));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
---
|
||||
```
|
||||
|
||||
For dynamic pages, set `prerender = false` to fetch content on each request:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
---
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Use server rendering for frequently updated content. Use static generation for content that
|
||||
changes rarely and benefits from CDN caching.
|
||||
</Aside>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Getting Started" icon="rocket">
|
||||
[Create your first EmDash site](/getting-started/) in under 5 minutes.
|
||||
</Card>
|
||||
<Card title="Querying Content" icon="document">
|
||||
[Learn the query API](/guides/querying-content/) in detail.
|
||||
</Card>
|
||||
<Card title="Create a Blog" icon="pencil">
|
||||
[Build a complete blog](/guides/create-a-blog/) with categories and tags.
|
||||
</Card>
|
||||
<Card title="Deploy to Cloudflare" icon="external">
|
||||
[Take your site to production](/deployment/cloudflare/) on Workers.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
398
docs/src/content/docs/coming-from/wordpress.mdx
Normal file
@@ -0,0 +1,398 @@
|
||||
---
|
||||
title: EmDash for WordPress Developers
|
||||
description: A guide to EmDash's features and concepts for developers familiar with WordPress
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash brings familiar WordPress concepts—posts, pages, taxonomies, menus, widgets, and a media library—into a modern Astro stack. Your content management knowledge transfers directly.
|
||||
|
||||
## What Stays Familiar
|
||||
|
||||
The concepts you know from WordPress are first-class features in EmDash:
|
||||
|
||||
- **Collections** work like Custom Post Types—define your content structure, query it in templates
|
||||
- **Taxonomies** work the same way—hierarchical (like categories) and flat (like tags)
|
||||
- **Menus** with drag-and-drop ordering and nested items
|
||||
- **Widget Areas** for sidebars and dynamic content regions
|
||||
- **Media library** with upload, organization, and image management
|
||||
- **Admin UI** that content editors can use without touching code
|
||||
|
||||
<Aside type="tip">
|
||||
You don't need to know React or any specific JavaScript framework. Astro components use HTML with
|
||||
simple template expressions—closer to PHP templates than to React.
|
||||
</Aside>
|
||||
|
||||
## What's Different
|
||||
|
||||
The implementation changes, but the mental model stays the same:
|
||||
|
||||
<CardGrid>
|
||||
<Card title="TypeScript instead of PHP" icon="seti:typescript">
|
||||
Templates are Astro components. The syntax is cleaner, but the concept is the same: server code
|
||||
that outputs HTML.
|
||||
</Card>
|
||||
<Card title="Content APIs instead of WP_Query" icon="document">
|
||||
Query functions like `getEmDashCollection()` replace `WP_Query`. No SQL, just function calls.
|
||||
</Card>
|
||||
<Card title="File-based routing" icon="puzzle">
|
||||
Files in `src/pages/` become URLs. No rewrite rules or template hierarchy to memorize.
|
||||
</Card>
|
||||
<Card title="Components instead of template parts" icon="rocket">
|
||||
Import and use components. Same idea as `get_template_part()`, better organization.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| WordPress | EmDash | Notes |
|
||||
| ---------------------- | ------------------------------------ | --------------------------------- |
|
||||
| Custom Post Types | Collections | Define via admin UI or API |
|
||||
| `WP_Query` | `getEmDashCollection()` | Filters, limits, taxonomy queries |
|
||||
| `get_post()` | `getEmDashEntry()` | Returns entry or null |
|
||||
| Categories/Tags | Taxonomies | Hierarchical support preserved |
|
||||
| `register_nav_menus()` | `getMenu()` | First-class menu support |
|
||||
| `register_sidebar()` | `getWidgetArea()` | First-class widget areas |
|
||||
| `bloginfo('name')` | `getSiteSetting("title")` | Site settings API |
|
||||
| `the_content()` | `<PortableText />` | Structured content rendering |
|
||||
| Shortcodes | Portable Text blocks | Custom components |
|
||||
| `add_action/filter()` | Plugin hooks | `content:beforeSave`, etc. |
|
||||
| `wp_options` | `ctx.kv` | Key-value storage |
|
||||
| Theme directory | `src/` directory | Components, layouts, pages |
|
||||
| `functions.php` | `astro.config.mjs` + EmDash config | Build and runtime config |
|
||||
|
||||
## Content APIs
|
||||
|
||||
### Querying Collections
|
||||
|
||||
WordPress queries use `WP_Query` or helper functions. EmDash uses typed query functions.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="archive.php"
|
||||
<?php
|
||||
$posts = new WP_Query([
|
||||
'post_type' => 'post',
|
||||
'posts_per_page' => 10,
|
||||
'post_status' => 'publish',
|
||||
'category_name' => 'news',
|
||||
]);
|
||||
|
||||
while ($posts->have_posts()) :
|
||||
$posts->the_post();
|
||||
?>
|
||||
|
||||
<h2><?php the_title(); ?></h2>
|
||||
<?php the_excerpt(); ?>
|
||||
<?php endwhile; ?>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/pages/posts/index.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
limit: 10,
|
||||
where: { category: "news" },
|
||||
});
|
||||
|
||||
---
|
||||
|
||||
{posts.map((post) => (
|
||||
|
||||
<article>
|
||||
<h2>{post.data.title}</h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
</article>
|
||||
))}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Getting a Single Entry
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="single.php"
|
||||
<?php
|
||||
$post = get_post($id);
|
||||
?>
|
||||
<article>
|
||||
<h1><?php echo $post->post_title; ?></h1>
|
||||
<?php echo apply_filters('the_content', $post->post_content); ?>
|
||||
</article>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
|
||||
## if (!post) return Astro.redirect("/404");
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Template Hierarchy
|
||||
|
||||
WordPress uses a template hierarchy to select which file renders a page. Astro uses explicit file-based routing.
|
||||
|
||||
| WordPress Template | EmDash Equivalent |
|
||||
| --------------------------- | ----------------------------------- |
|
||||
| `index.php` | `src/pages/index.astro` |
|
||||
| `single.php` | `src/pages/posts/[slug].astro` |
|
||||
| `single-{type}.php` | `src/pages/{type}/[slug].astro` |
|
||||
| `page.php` | `src/pages/pages/[slug].astro` |
|
||||
| `archive.php` | `src/pages/posts/index.astro` |
|
||||
| `archive-{type}.php` | `src/pages/{type}/index.astro` |
|
||||
| `category.php` | `src/pages/categories/[slug].astro` |
|
||||
| `tag.php` | `src/pages/tags/[slug].astro` |
|
||||
| `search.php` | `src/pages/search.astro` |
|
||||
| `404.php` | `src/pages/404.astro` |
|
||||
| `header.php` / `footer.php` | `src/layouts/Base.astro` |
|
||||
| `sidebar.php` | `src/components/Sidebar.astro` |
|
||||
|
||||
<Aside type="tip">
|
||||
Astro's routing is more explicit than WordPress's hierarchy. Each route is a file. Dynamic
|
||||
segments use `[param]` syntax.
|
||||
</Aside>
|
||||
|
||||
## Template Parts → Components
|
||||
|
||||
WordPress template parts become Astro components:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="functions.php / template"
|
||||
// In template:
|
||||
get_template_part('template-parts/content', 'post');
|
||||
|
||||
// template-parts/content-post.php:
|
||||
|
||||
<article class="post">
|
||||
<h2><?php the_title(); ?></h2>
|
||||
<?php the_excerpt(); ?>
|
||||
</article>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/components/PostCard.astro"
|
||||
---
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
|
||||
<article class="post">
|
||||
<h2>{post.data.title}</h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import PostCard from "../components/PostCard.astro";
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
---
|
||||
|
||||
{posts.map((post) => <PostCard {post} />)}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
|
||||
</Tabs>
|
||||
|
||||
## Menus
|
||||
|
||||
EmDash has first-class menu support with automatic URL resolution:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="header.php"
|
||||
<?php
|
||||
wp_nav_menu([
|
||||
'theme_location' => 'primary',
|
||||
'container' => 'nav',
|
||||
]);
|
||||
?>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/components/Header.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
## const menu = await getMenu("primary");
|
||||
|
||||
<nav>
|
||||
<ul>
|
||||
{menu?.items.map((item) => (
|
||||
<li>
|
||||
<a href={item.url}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Menus are created via the admin UI, seed files, or WordPress import.
|
||||
|
||||
## Widget Areas
|
||||
|
||||
Widget areas work like sidebars in WordPress:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="sidebar.php"
|
||||
<?php if (is_active_sidebar('sidebar-1')) : ?>
|
||||
<aside>
|
||||
<?php dynamic_sidebar('sidebar-1'); ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/components/Sidebar.astro"
|
||||
---
|
||||
import { getWidgetArea } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
## const sidebar = await getWidgetArea("sidebar");
|
||||
|
||||
{sidebar && (
|
||||
|
||||
<aside>
|
||||
{sidebar.widgets.map((widget) => {
|
||||
if (widget.type === "content") {
|
||||
return <PortableText value={widget.content} />;
|
||||
}
|
||||
// Handle other widget types
|
||||
})}
|
||||
</aside>
|
||||
)}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Site Settings
|
||||
|
||||
Site options and customizer settings map to `getSiteSetting()`:
|
||||
|
||||
| WordPress | EmDash |
|
||||
| --------------------------- | ------------------------------ |
|
||||
| `bloginfo('name')` | `getSiteSetting("title")` |
|
||||
| `bloginfo('description')` | `getSiteSetting("tagline")` |
|
||||
| `get_custom_logo()` | `getSiteSetting("logo")` |
|
||||
| `get_option('date_format')` | `getSiteSetting("dateFormat")` |
|
||||
| `home_url()` | `Astro.site` |
|
||||
|
||||
```ts
|
||||
import { getSiteSetting } from "emdash";
|
||||
|
||||
const title = await getSiteSetting("title");
|
||||
const logo = await getSiteSetting("logo"); // Returns { mediaId, alt, url }
|
||||
```
|
||||
|
||||
## Taxonomies
|
||||
|
||||
Taxonomies work the same conceptually—hierarchical (like categories) or flat (like tags):
|
||||
|
||||
```ts
|
||||
import { getTaxonomyTerms, getEntryTerms, getTerm } from "emdash";
|
||||
|
||||
// Get all categories
|
||||
const categories = await getTaxonomyTerms("categories");
|
||||
|
||||
// Get a specific term
|
||||
const news = await getTerm("categories", "news");
|
||||
|
||||
// Get terms for a post
|
||||
const postCategories = await getEntryTerms("posts", postId, "categories");
|
||||
```
|
||||
|
||||
## Hooks → Plugin System
|
||||
|
||||
WordPress hooks (`add_action`, `add_filter`) become EmDash plugin hooks:
|
||||
|
||||
| WordPress Hook | EmDash Hook | Purpose |
|
||||
| --------------- | ----------------------- | ---------------------------- |
|
||||
| `save_post` | `content:beforeSave` | Modify content before saving |
|
||||
| `the_content` | PortableText components | Transform rendered content |
|
||||
| `pre_get_posts` | Query options | Filter queries |
|
||||
| `wp_head` | Layout `<head>` | Add head content |
|
||||
| `wp_footer` | Layout before `</body>` | Add footer content |
|
||||
|
||||
## What's Better in EmDash
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Type Safety" icon="seti:typescript">
|
||||
TypeScript throughout. Collections, queries, and components are fully typed. No more guessing
|
||||
field names or return types.
|
||||
</Card>
|
||||
<Card title="Performance" icon="rocket">
|
||||
No PHP overhead. Static generation by default. Server rendering when needed. Edge deployment
|
||||
ready.
|
||||
</Card>
|
||||
<Card title="Modern DX" icon="laptop">
|
||||
Hot module replacement. Component-based architecture. Modern tooling (Vite, TypeScript, ESLint).
|
||||
</Card>
|
||||
<Card title="Git-based Deployments" icon="github">
|
||||
Code and templates in git. Content in the database. No FTP, no file permissions, no hacked
|
||||
sites.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
### Preview Links
|
||||
|
||||
EmDash generates secure preview URLs with HMAC-signed tokens. Content editors can preview drafts without logging into production—share a link, not credentials.
|
||||
|
||||
### No Plugin Conflicts
|
||||
|
||||
WordPress plugin conflicts disappear. EmDash plugins run in isolated contexts with explicit APIs. No global state pollution.
|
||||
|
||||
## Content Editor Experience
|
||||
|
||||
Content editors use the EmDash admin panel, similar to wp-admin:
|
||||
|
||||
- **Dashboard** with recent activity
|
||||
- **Collection listings** with search, filter, and bulk actions
|
||||
- **Rich editor** for content (Portable Text, not Gutenberg)
|
||||
- **Media library** with drag-and-drop upload
|
||||
- **Menu builder** with drag-and-drop ordering
|
||||
- **Widget area editor** for sidebar content
|
||||
|
||||
The editing experience is familiar. The technology underneath is modern.
|
||||
|
||||
## Migration Path
|
||||
|
||||
EmDash imports WordPress content directly:
|
||||
|
||||
1. Export from WordPress (Tools → Export)
|
||||
2. Upload the `.xml` file in EmDash's admin
|
||||
3. Map post types to collections
|
||||
4. Import content and media
|
||||
|
||||
Posts, pages, taxonomies, menus, and media transfer. Gutenberg blocks convert to Portable Text. Custom fields are analyzed and mapped.
|
||||
|
||||
See the [WordPress Migration Guide](/migration/from-wordpress/) for complete instructions.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Getting Started](/getting-started/)** — Set up your first EmDash site
|
||||
- **[Querying Content](/guides/querying-content/)** — Deep dive into content APIs
|
||||
- **[Taxonomies](/guides/taxonomies/)** — Categories, tags, and custom taxonomies
|
||||
- **[Menus](/guides/menus/)** — Navigation menus
|
||||
- **[Migrate from WordPress](/migration/from-wordpress/)** — Import existing content
|
||||
378
docs/src/content/docs/concepts/admin-panel.mdx
Normal file
@@ -0,0 +1,378 @@
|
||||
---
|
||||
title: Admin Panel
|
||||
description: The EmDash admin panel—a React SPA with TanStack Router, TanStack Query, and Kumo components.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
The EmDash admin panel is a React single-page application embedded in your Astro site. It provides a complete content management interface for editors and administrators.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ Astro Shell │
|
||||
│ /_emdash/admin/[...path].astro │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ React SPA │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐ │ │
|
||||
│ │ │ TanStack │ │ TanStack │ │ Kumo │ │ │
|
||||
│ │ │ Router │ │ Query │ │ Components │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └─────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ REST API Client │ │ │
|
||||
│ │ │ /_emdash/api/* │ │ │
|
||||
│ │ └────────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The admin is a "big island" React app. Astro handles the shell and authentication; all navigation and rendering inside the admin is client-side.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology | Purpose |
|
||||
| ----------- | --------------------- | ---------------------------------------- |
|
||||
| **Routing** | TanStack Router | Type-safe client-side routing |
|
||||
| **Data** | TanStack Query | Server state, caching, mutations |
|
||||
| **UI** | Kumo | Accessible components (Base UI + Tailwind) |
|
||||
| **Tables** | TanStack Table | Sorting, filtering, pagination |
|
||||
| **Forms** | React Hook Form + Zod | Validation matching server schema |
|
||||
| **Icons** | Phosphor | Consistent iconography |
|
||||
| **Editor** | TipTap | Rich text editing (Portable Text) |
|
||||
|
||||
<Aside type="note">
|
||||
Kumo is Cloudflare's design system, built on Base UI primitives with Tailwind styling. It's
|
||||
installed as a dependency of the admin package.
|
||||
</Aside>
|
||||
|
||||
## Route Structure
|
||||
|
||||
The admin mounts at `/_emdash/admin/` and uses client-side routing:
|
||||
|
||||
| Path | Screen |
|
||||
| -------------------------- | --------------------------- |
|
||||
| `/` | Dashboard |
|
||||
| `/content/:collection` | Content list |
|
||||
| `/content/:collection/:id` | Content editor |
|
||||
| `/content/:collection/new` | New entry |
|
||||
| `/media` | Media library |
|
||||
| `/content-types` | Schema builder (admin only) |
|
||||
| `/menus` | Navigation menus |
|
||||
| `/widgets` | Widget areas |
|
||||
| `/taxonomies` | Category/tag management |
|
||||
| `/settings` | Site settings |
|
||||
| `/plugins/:pluginId/*` | Plugin pages |
|
||||
|
||||
<Aside type="tip">
|
||||
The `/content-types` route is only visible to administrators. Editors see only the content they
|
||||
have permission to manage.
|
||||
</Aside>
|
||||
|
||||
## Manifest-Driven UI
|
||||
|
||||
The admin doesn't hardcode knowledge of collections or plugins. Instead, it fetches a manifest from the server:
|
||||
|
||||
```
|
||||
GET /_emdash/api/manifest
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Blog Posts",
|
||||
"labelSingular": "Post",
|
||||
"icon": "file-text",
|
||||
"supports": ["drafts", "revisions", "preview"],
|
||||
"fields": [
|
||||
{ "slug": "title", "type": "string", "required": true },
|
||||
{ "slug": "content", "type": "portableText" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
{
|
||||
"id": "audit-log",
|
||||
"label": "Audit Log",
|
||||
"adminPages": [{ "path": "history", "label": "Audit History" }],
|
||||
"widgets": [{ "id": "recent-activity", "title": "Recent Activity" }]
|
||||
}
|
||||
],
|
||||
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
|
||||
"version": "abc123"
|
||||
}
|
||||
```
|
||||
|
||||
The admin builds its navigation, forms, and editors entirely from this manifest. Benefits:
|
||||
|
||||
- **Schema changes appear immediately** — No admin rebuild needed
|
||||
- **Plugin UI integrates automatically** — Pages and widgets from the manifest
|
||||
- **Type safety at the boundary** — Zod schemas stay on the server
|
||||
|
||||
## Data Flow
|
||||
|
||||
<Steps>
|
||||
1. **Admin SPA loads** — TanStack Router initializes 2. **Fetch manifest** — TanStack Query caches
|
||||
collection/plugin metadata 3. **Build navigation** — Sidebar generated from manifest 4. **User
|
||||
navigates** — Client-side routing, no page reload 5. **Fetch data** — TanStack Query requests
|
||||
content from REST APIs 6. **Render forms** — Field editors generated from manifest field
|
||||
descriptors 7. **Submit changes** — Mutations via TanStack Query, optimistic updates 8. **Server
|
||||
validates** — Zod schemas on the server, errors returned as JSON
|
||||
</Steps>
|
||||
|
||||
## REST API Endpoints
|
||||
|
||||
The admin communicates exclusively through REST APIs:
|
||||
|
||||
### Content APIs
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| -------- | ------------------------------------------ | -------------------- |
|
||||
| `GET` | `/api/content/:collection` | List entries |
|
||||
| `POST` | `/api/content/:collection` | Create entry |
|
||||
| `GET` | `/api/content/:collection/:id` | Get entry |
|
||||
| `PUT` | `/api/content/:collection/:id` | Update entry |
|
||||
| `DELETE` | `/api/content/:collection/:id` | Soft delete entry |
|
||||
| `GET` | `/api/content/:collection/:id/revisions` | List revisions |
|
||||
| `POST` | `/api/content/:collection/:id/preview-url` | Generate preview URL |
|
||||
|
||||
### Schema APIs
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| -------- | --------------------------------------------- | ------------------ |
|
||||
| `GET` | `/api/schema` | Export full schema |
|
||||
| `GET` | `/api/schema/collections` | List collections |
|
||||
| `POST` | `/api/schema/collections` | Create collection |
|
||||
| `PUT` | `/api/schema/collections/:slug` | Update collection |
|
||||
| `DELETE` | `/api/schema/collections/:slug` | Delete collection |
|
||||
| `POST` | `/api/schema/collections/:slug/fields` | Add field |
|
||||
| `PUT` | `/api/schema/collections/:slug/fields/:field` | Update field |
|
||||
| `DELETE` | `/api/schema/collections/:slug/fields/:field` | Delete field |
|
||||
|
||||
### Media APIs
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
| -------- | ------------------------ | ----------------------- |
|
||||
| `GET` | `/api/media` | List media items |
|
||||
| `POST` | `/api/media/upload-url` | Get signed upload URL |
|
||||
| `POST` | `/api/media/:id/confirm` | Confirm upload complete |
|
||||
| `DELETE` | `/api/media/:id` | Delete media item |
|
||||
| `GET` | `/api/media/file/:key` | Serve media file |
|
||||
|
||||
### Other APIs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| ---------------------- | ------------------------ |
|
||||
| `/api/settings` | Site settings (GET/POST) |
|
||||
| `/api/menus/*` | Navigation menus |
|
||||
| `/api/widget-areas/*` | Widget management |
|
||||
| `/api/taxonomies/*` | Taxonomy terms |
|
||||
| `/api/admin/plugins/*` | Plugin state |
|
||||
|
||||
## Pagination
|
||||
|
||||
All list endpoints use cursor-based pagination:
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [...],
|
||||
"nextCursor": "eyJpZCI6IjAxSjEyMzQ1NiJ9"
|
||||
}
|
||||
```
|
||||
|
||||
Fetch the next page:
|
||||
|
||||
```
|
||||
GET /api/content/posts?cursor=eyJpZCI6IjAxSjEyMzQ1NiJ9
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
Cursor pagination provides consistent results even when content is added or removed between
|
||||
requests.
|
||||
</Aside>
|
||||
|
||||
## Plugin Admin UI
|
||||
|
||||
Plugins can extend the admin with pages and dashboard widgets. The integration generates a virtual module with static imports:
|
||||
|
||||
```ts
|
||||
// virtual:emdash/plugin-admins (generated)
|
||||
import * as pluginAdmin0 from "@emdash-cms/plugin-seo/admin";
|
||||
import * as pluginAdmin1 from "@emdash-cms/plugin-analytics/admin";
|
||||
|
||||
export const pluginAdmins = {
|
||||
seo: pluginAdmin0,
|
||||
analytics: pluginAdmin1,
|
||||
};
|
||||
```
|
||||
|
||||
### Plugin Pages
|
||||
|
||||
Plugin pages mount under `/_emdash/admin/plugins/:pluginId/*`:
|
||||
|
||||
```tsx
|
||||
// @emdash-cms/plugin-seo/src/admin.tsx
|
||||
export const pages = [
|
||||
{
|
||||
path: "settings",
|
||||
component: SEOSettingsPage,
|
||||
label: "SEO Settings",
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
Renders at: `/_emdash/admin/plugins/seo/settings`
|
||||
|
||||
### Dashboard Widgets
|
||||
|
||||
Plugins can add widgets to the dashboard:
|
||||
|
||||
```tsx
|
||||
export const widgets = [
|
||||
{
|
||||
id: "seo-overview",
|
||||
component: SEOWidget,
|
||||
title: "SEO Overview",
|
||||
size: "half", // "full" | "half" | "third"
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Plugins can only mount pages under their own namespace (`/plugins/:pluginId/*`). They cannot
|
||||
override core admin routes.
|
||||
</Aside>
|
||||
|
||||
## Authentication
|
||||
|
||||
The admin shell route enforces authentication via Astro middleware:
|
||||
|
||||
```ts
|
||||
// Simplified middleware logic
|
||||
export async function onRequest({ request, locals }, next) {
|
||||
const session = await getSession(request);
|
||||
|
||||
if (request.url.includes("/_emdash/admin")) {
|
||||
if (!session?.user) {
|
||||
return redirect("/_emdash/admin/login");
|
||||
}
|
||||
locals.user = session.user;
|
||||
}
|
||||
|
||||
return next();
|
||||
}
|
||||
```
|
||||
|
||||
The admin SPA itself doesn't handle login—that's an Astro page that sets a session cookie.
|
||||
|
||||
## Role-Based Access
|
||||
|
||||
Different roles see different parts of the admin:
|
||||
|
||||
| Role | Visible Sections |
|
||||
| ------------- | ------------------------------------------ |
|
||||
| **Editor** | Dashboard, assigned collections, media |
|
||||
| **Admin** | + Content Types, all collections, settings |
|
||||
| **Developer** | + CLI access, generated types |
|
||||
|
||||
The manifest endpoint filters collections and features based on the requesting user's role.
|
||||
|
||||
## Content Editor
|
||||
|
||||
The content editor generates forms dynamically based on field definitions:
|
||||
|
||||
```tsx
|
||||
// Simplified editor rendering
|
||||
function ContentEditor({ collection, fields }) {
|
||||
return (
|
||||
<form>
|
||||
{fields.map((field) => (
|
||||
<FieldWidget
|
||||
key={field.slug}
|
||||
type={field.type}
|
||||
label={field.label}
|
||||
required={field.required}
|
||||
options={field.options}
|
||||
/>
|
||||
))}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Each field type has a corresponding widget:
|
||||
|
||||
| Field Type | Widget |
|
||||
| -------------- | ---------------- |
|
||||
| `string` | Text input |
|
||||
| `text` | Textarea |
|
||||
| `number` | Number input |
|
||||
| `boolean` | Toggle switch |
|
||||
| `datetime` | Date/time picker |
|
||||
| `select` | Dropdown |
|
||||
| `multiSelect` | Multi-select |
|
||||
| `portableText` | TipTap editor |
|
||||
| `image` | Media picker |
|
||||
| `reference` | Entry picker |
|
||||
|
||||
## Rich Text Editor
|
||||
|
||||
Portable Text fields use TipTap (ProseMirror) for editing:
|
||||
|
||||
```
|
||||
User types → TipTap (ProseMirror JSON) → Save → Portable Text (DB)
|
||||
Load → Portable Text (DB) → TipTap (ProseMirror JSON) → Display
|
||||
```
|
||||
|
||||
Conversion happens at load/save boundaries via `portableTextToProsemirror()` and `prosemirrorToPortableText()`.
|
||||
|
||||
Supported blocks:
|
||||
|
||||
- Paragraphs, headings (H1-H6)
|
||||
- Bullet and numbered lists
|
||||
- Blockquotes, code blocks
|
||||
- Images (from media library)
|
||||
- Links
|
||||
|
||||
Unknown blocks from plugins or imports are preserved as read-only placeholders.
|
||||
|
||||
## Media Library
|
||||
|
||||
The media library provides:
|
||||
|
||||
- Grid and list views
|
||||
- Search and filter by type, date
|
||||
- Drag-and-drop upload
|
||||
- Image preview with metadata
|
||||
- Bulk selection and delete
|
||||
|
||||
Uploads use signed URLs for direct client-to-storage upload:
|
||||
|
||||
<Steps>
|
||||
1. **Request upload URL** — `POST /api/media/upload-url` 2. **Upload directly** — Client PUTs file
|
||||
to signed URL (R2/S3) 3. **Confirm upload** — `POST /api/media/:id/confirm` 4. **Server extracts
|
||||
metadata** — Dimensions, MIME type, etc.
|
||||
</Steps>
|
||||
|
||||
This approach bypasses Workers body size limits and provides real upload progress.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Getting Started" icon="rocket">
|
||||
[Set up your first EmDash site](/getting-started/).
|
||||
</Card>
|
||||
<Card title="Plugin Development" icon="puzzle">
|
||||
[Build admin UI extensions](/plugins/admin-ui/).
|
||||
</Card>
|
||||
<Card title="Architecture" icon="open-book">
|
||||
Review the full [system architecture](/concepts/architecture/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
264
docs/src/content/docs/concepts/architecture.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
title: Architecture
|
||||
description: How EmDash works under the hood—database-first schema, Live Collections, plugin system, and admin panel.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash integrates deeply with Astro to provide a complete CMS experience. This page explains the key architectural decisions and how the pieces fit together.
|
||||
|
||||
## High-Level Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Your Astro Site │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ EmDash Integration │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
|
||||
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
|
||||
│ │ │ APIs │ │ Panel │ │ │ │ │
|
||||
│ │ └──────────────┘ └──────────────┘ └───────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌──────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Data Layer │ │ │
|
||||
│ │ │ Database (D1/SQLite) + Storage (R2/S3) │ │ │
|
||||
│ │ └──────────────────────────────────────────────────────┘ │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Astro Framework │ │
|
||||
│ │ Live Collections · Middleware · Sessions │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
EmDash runs as an Astro integration. It injects routes for the admin panel and REST APIs, provides a content loader for Live Collections, and manages database migrations and storage connections.
|
||||
|
||||
## Database-First Schema
|
||||
|
||||
Unlike traditional CMSs that define schema in code, EmDash stores schema definitions in the database itself. Two system tables track your content structure:
|
||||
|
||||
- `_emdash_collections` — Collection metadata (slug, label, features)
|
||||
- `_emdash_fields` — Field definitions for each collection
|
||||
|
||||
When you create a "products" collection with title and price fields via the admin UI, EmDash:
|
||||
|
||||
1. Inserts records into `_emdash_collections` and `_emdash_fields`
|
||||
2. Runs `ALTER TABLE` to create `ec_products` with the appropriate columns
|
||||
|
||||
This design enables:
|
||||
|
||||
- **Runtime schema modification** — Create and edit content types without code changes or rebuilds
|
||||
- **Non-developer-friendly setup** — Content editors can design their data model through the UI
|
||||
- **Real SQL columns** — Proper indexing, foreign keys, and query optimization
|
||||
|
||||
<Aside type="tip">
|
||||
Run `npx emdash types` to generate TypeScript types from the live schema. This gives you type
|
||||
safety for dynamically-defined collections.
|
||||
</Aside>
|
||||
|
||||
## Per-Collection Tables
|
||||
|
||||
Each collection gets its own SQLite table with an `ec_` prefix:
|
||||
|
||||
```sql
|
||||
-- Created when "posts" collection is added
|
||||
CREATE TABLE ec_posts (
|
||||
-- System columns (always present)
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE,
|
||||
status TEXT DEFAULT 'draft', -- draft, published, scheduled
|
||||
author_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT,
|
||||
deleted_at TEXT, -- Soft delete
|
||||
version INTEGER DEFAULT 1, -- Optimistic locking
|
||||
|
||||
-- Content columns (from your field definitions)
|
||||
title TEXT NOT NULL,
|
||||
content JSON, -- Portable Text
|
||||
excerpt TEXT
|
||||
);
|
||||
```
|
||||
|
||||
**Why per-collection tables instead of a single content table with JSON?**
|
||||
|
||||
- Real SQL columns enable proper indexing and queries
|
||||
- Foreign keys work correctly
|
||||
- Schema is self-documenting in the database
|
||||
- No JSON parsing overhead for field access
|
||||
- Database tools can inspect schema directly
|
||||
|
||||
## Live Collections Integration
|
||||
|
||||
EmDash uses Astro 6's Live Collections to serve content at runtime. Content changes are immediately available without static rebuilds.
|
||||
|
||||
The `emdashLoader()` implements Astro's `LiveLoader` interface:
|
||||
|
||||
```ts
|
||||
// src/live.config.ts
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
A single `_emdash` Astro collection wraps all your content types. The loader filters by type
|
||||
internally when you call `getEmDashCollection("posts")`.
|
||||
</Aside>
|
||||
|
||||
Query content using the provided wrapper functions:
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
// Get all published posts
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
|
||||
// Get drafts
|
||||
const { entries: drafts } = await getEmDashCollection("posts", {
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
// Get a single entry by slug
|
||||
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
|
||||
```
|
||||
|
||||
## Route Injection
|
||||
|
||||
The EmDash integration uses Astro's `injectRoute` API to add admin and API routes:
|
||||
|
||||
| Path Pattern | Purpose |
|
||||
| ------------------------------------- | ------------------------------------- |
|
||||
| `/_emdash/admin/[...path]` | Admin panel SPA |
|
||||
| `/_emdash/api/manifest` | Admin manifest (collections, plugins) |
|
||||
| `/_emdash/api/content/[collection]` | CRUD for content entries |
|
||||
| `/_emdash/api/media/*` | Media library operations |
|
||||
| `/_emdash/api/schema/*` | Schema management |
|
||||
| `/_emdash/api/settings` | Site settings |
|
||||
| `/_emdash/api/menus/*` | Navigation menus |
|
||||
| `/_emdash/api/taxonomies/*` | Categories, tags, custom taxonomies |
|
||||
|
||||
Routes are injected from the `emdash` package—nothing is copied into your project.
|
||||
|
||||
## Data Layer
|
||||
|
||||
EmDash uses [Kysely](https://kysely.dev) for type-safe SQL queries across all supported databases:
|
||||
|
||||
<CardGrid>
|
||||
<Card title="SQLite" icon="laptop">
|
||||
Local development with `sqlite({ url: "file:./data.db" })`
|
||||
</Card>
|
||||
<Card title="D1" icon="cloudflare">
|
||||
Cloudflare's serverless SQL with `d1({ binding: "DB" })`
|
||||
</Card>
|
||||
<Card title="libSQL" icon="external">
|
||||
Remote SQLite with `libsql({ url: "...", authToken: "..." })`
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
Database configuration is passed to the integration in `astro.config.mjs`:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
import { local } from "emdash/storage";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Storage Abstraction
|
||||
|
||||
Media files are stored separately from the database. EmDash supports:
|
||||
|
||||
- **Local filesystem** — Development and simple deployments
|
||||
- **Cloudflare R2** — S3-compatible object storage on the edge
|
||||
- **S3-compatible** — Any S3-compatible object storage
|
||||
|
||||
Uploads use signed URLs for direct client-to-storage uploads, bypassing Workers body size limits.
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Plugins extend EmDash through a WordPress-inspired hook system:
|
||||
|
||||
- **Content hooks** — `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, `content:afterDelete`
|
||||
- **Media hooks** — `media:beforeUpload`, `media:afterUpload`
|
||||
- **Isolated storage** — Each plugin gets namespaced KV access
|
||||
- **Admin UI extensions** — Dashboard widgets, settings pages, custom field editors
|
||||
|
||||
Plugins can run in two modes:
|
||||
|
||||
1. **Native** — Full access to the host environment (for first-party plugins)
|
||||
2. **Sandboxed** — Run in V8 isolates with capability-based permissions (for third-party plugins on Cloudflare)
|
||||
|
||||
```ts
|
||||
// astro.config.mjs
|
||||
import { seoPlugin } from "@emdash-cms/plugin-seo";
|
||||
|
||||
emdash({
|
||||
plugins: [seoPlugin({ maxTitleLength: 60 })],
|
||||
});
|
||||
```
|
||||
|
||||
## Request Flow
|
||||
|
||||
A typical content request follows this path:
|
||||
|
||||
<Steps>
|
||||
1. **Astro receives request** — Your page component runs 2. **Query content** —
|
||||
`getEmDashCollection()` calls Astro's `getLiveCollection()` 3. **Loader executes** —
|
||||
`emdashLoader` queries the appropriate `ec_*` table via Kysely 4. **Data returned** — Entries
|
||||
are mapped to Astro's entry format with `id`, `slug`, and `data` 5. **Page renders** — Your
|
||||
component receives the content and renders HTML
|
||||
</Steps>
|
||||
|
||||
For admin requests:
|
||||
|
||||
<Steps>
|
||||
1. **Middleware authenticates** — Validates session token 2. **API route handles request** — CRUD
|
||||
operations via repositories 3. **Hooks fire** — `beforeCreate`, `afterUpdate`, etc. 4. **Database
|
||||
updates** — Kysely executes SQL 5. **Response returned** — JSON response to admin SPA
|
||||
</Steps>
|
||||
|
||||
## Virtual Modules
|
||||
|
||||
EmDash generates virtual modules at build time to configure the runtime:
|
||||
|
||||
| Module | Purpose |
|
||||
| -------------------------------- | ----------------------------------- |
|
||||
| `virtual:emdash/config` | Database and storage configuration |
|
||||
| `virtual:emdash/dialect` | Database dialect factory |
|
||||
| `virtual:emdash/plugin-admins` | Static imports for plugin admin UIs |
|
||||
|
||||
This approach ensures bundlers can properly resolve and tree-shake plugin code.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Collections" icon="document">
|
||||
Learn about [content collections and field types](/concepts/collections/).
|
||||
</Card>
|
||||
<Card title="Content Model" icon="open-book">
|
||||
Understand the [database-first content model](/concepts/content-model/).
|
||||
</Card>
|
||||
<Card title="Admin Panel" icon="setting">
|
||||
Explore the [admin panel architecture](/concepts/admin-panel/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
382
docs/src/content/docs/concepts/collections.mdx
Normal file
@@ -0,0 +1,382 @@
|
||||
---
|
||||
title: Collections & Fields
|
||||
description: Define content types with collections and fields—supported field types, validation, and relationships.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
import contentTypesImg from "../../../assets/screenshots/admin-content-types.png";
|
||||
|
||||
Collections are the foundation of EmDash's content model. Each collection represents a content type (posts, pages, products) and contains field definitions that determine the shape of your data.
|
||||
|
||||
## Creating Collections
|
||||
|
||||
Create collections through the admin panel under **Content Types**. Each collection has:
|
||||
|
||||
<img src={contentTypesImg.src} alt="EmDash content types showing Pages, Posts, and custom collections with their features" />
|
||||
|
||||
| Property | Description |
|
||||
| --------------- | ---------------------------------------------------- |
|
||||
| `slug` | URL-safe identifier (e.g., `posts`, `products`) |
|
||||
| `label` | Display name (e.g., "Blog Posts") |
|
||||
| `labelSingular` | Singular form (e.g., "Post") |
|
||||
| `description` | Optional description for editors |
|
||||
| `icon` | Lucide icon name for the admin sidebar |
|
||||
| `supports` | Features like drafts, revisions, preview, scheduling, search, seo |
|
||||
|
||||
<Aside type="note">
|
||||
Some collection slugs are reserved: `content`, `media`, `users`, `revisions`, `taxonomies`,
|
||||
`options`, `audit_logs`.
|
||||
</Aside>
|
||||
|
||||
## Collection Features
|
||||
|
||||
When creating a collection, enable the features you need:
|
||||
|
||||
| Feature | Description |
|
||||
| ------------ | ---------------------------------------------- |
|
||||
| `drafts` | Enable draft/published workflow |
|
||||
| `revisions` | Track content history with version snapshots |
|
||||
| `preview` | Generate signed preview URLs for draft content |
|
||||
| `scheduling` | Schedule content to publish at a future date |
|
||||
|
||||
```ts
|
||||
// Example collection with all features enabled
|
||||
{
|
||||
slug: "posts",
|
||||
label: "Blog Posts",
|
||||
labelSingular: "Post",
|
||||
supports: ["drafts", "revisions", "preview", "scheduling"]
|
||||
}
|
||||
```
|
||||
|
||||
## Field Types
|
||||
|
||||
EmDash supports 15 field types that map to SQLite column types:
|
||||
|
||||
### Text Fields
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="string">
|
||||
Short text input. Maps to `TEXT` column.
|
||||
|
||||
```ts
|
||||
{ slug: "title", type: "string", label: "Title" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="text">
|
||||
Multi-line textarea. Maps to `TEXT` column.
|
||||
|
||||
```ts
|
||||
{ slug: "excerpt", type: "text", label: "Excerpt" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="slug">
|
||||
URL-safe slug field. Maps to `TEXT` column.
|
||||
|
||||
```ts
|
||||
{ slug: "handle", type: "slug", label: "URL Handle" }
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Rich Content
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="portableText">
|
||||
Rich text editor (TipTap/ProseMirror). Stored as JSON.
|
||||
|
||||
```ts
|
||||
{ slug: "content", type: "portableText", label: "Content" }
|
||||
```
|
||||
|
||||
Portable Text is a block-based format that preserves structure without embedding HTML.
|
||||
</TabItem>
|
||||
<TabItem label="json">
|
||||
Arbitrary JSON data. Stored as JSON.
|
||||
|
||||
```ts
|
||||
{ slug: "metadata", type: "json", label: "Custom Metadata" }
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Numbers
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="number">
|
||||
Decimal numbers. Maps to `REAL` column.
|
||||
|
||||
```ts
|
||||
{ slug: "price", type: "number", label: "Price" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="integer">
|
||||
Whole numbers. Maps to `INTEGER` column.
|
||||
|
||||
```ts
|
||||
{ slug: "quantity", type: "integer", label: "Stock Quantity" }
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Booleans & Dates
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="boolean">
|
||||
True/false toggle. Maps to `INTEGER` (0/1).
|
||||
|
||||
```ts
|
||||
{ slug: "featured", type: "boolean", label: "Featured Post" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="datetime">
|
||||
Date and time picker. Stored as ISO 8601 string.
|
||||
|
||||
```ts
|
||||
{ slug: "eventDate", type: "datetime", label: "Event Date" }
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Selection
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="select">
|
||||
Single option from a list. Maps to `TEXT` column.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "status",
|
||||
type: "select",
|
||||
label: "Product Status",
|
||||
validation: {
|
||||
options: ["active", "discontinued", "coming_soon"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="multiSelect">
|
||||
Multiple options from a list. Stored as JSON array.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "features",
|
||||
type: "multiSelect",
|
||||
label: "Product Features",
|
||||
validation: {
|
||||
options: ["wireless", "waterproof", "eco-friendly"]
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Media & References
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="image">
|
||||
Image picker from media library. Stores media ID as `TEXT`.
|
||||
|
||||
```ts
|
||||
{ slug: "featuredImage", type: "image", label: "Featured Image" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="file">
|
||||
File picker from media library. Stores media ID as `TEXT`.
|
||||
|
||||
```ts
|
||||
{ slug: "attachment", type: "file", label: "PDF Attachment" }
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="reference">
|
||||
Reference to another collection's entry. Stores entry ID as `TEXT`.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "author",
|
||||
type: "reference",
|
||||
label: "Author",
|
||||
options: {
|
||||
collection: "authors"
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Field Properties
|
||||
|
||||
Every field supports these properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------- | ----------- | -------------------------------------------- |
|
||||
| `slug` | `string` | Column name in the database |
|
||||
| `label` | `string` | Display label in admin UI |
|
||||
| `type` | `FieldType` | One of the 15 field types |
|
||||
| `required` | `boolean` | Whether the field must have a value |
|
||||
| `unique` | `boolean` | Whether values must be unique across entries |
|
||||
| `defaultValue` | `unknown` | Default value for new entries |
|
||||
| `validation` | `object` | Type-specific validation rules |
|
||||
| `widget` | `string` | Custom widget identifier |
|
||||
| `options` | `object` | Widget-specific configuration |
|
||||
| `sortOrder` | `number` | Display order in the editor |
|
||||
|
||||
<Aside type="caution">
|
||||
Some field slugs are reserved and cannot be used: `id`, `slug`, `status`, `author_id`,
|
||||
`primary_byline_id`, `created_at`, `updated_at`, `published_at`, `scheduled_at`, `deleted_at`,
|
||||
`version`, `live_revision_id`, `draft_revision_id`.
|
||||
</Aside>
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The `validation` object varies by field type:
|
||||
|
||||
```ts
|
||||
interface FieldValidation {
|
||||
required?: boolean; // All types
|
||||
min?: number; // number, integer
|
||||
max?: number; // number, integer
|
||||
minLength?: number; // string, text
|
||||
maxLength?: number; // string, text
|
||||
pattern?: string; // string (regex)
|
||||
options?: string[]; // select, multiSelect
|
||||
}
|
||||
```
|
||||
|
||||
Example with validation:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "email",
|
||||
type: "string",
|
||||
label: "Email Address",
|
||||
required: true,
|
||||
unique: true,
|
||||
validation: {
|
||||
pattern: "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Widget Options
|
||||
|
||||
The `options` object configures field-specific UI behavior:
|
||||
|
||||
```ts
|
||||
interface FieldWidgetOptions {
|
||||
rows?: number; // text (textarea rows)
|
||||
showPreview?: boolean; // image, file
|
||||
collection?: string; // reference (target collection)
|
||||
allowMultiple?: boolean; // reference (multiple refs)
|
||||
[key: string]: unknown; // Custom widget options
|
||||
}
|
||||
```
|
||||
|
||||
Example reference field:
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "relatedProducts",
|
||||
type: "reference",
|
||||
label: "Related Products",
|
||||
options: {
|
||||
collection: "products",
|
||||
allowMultiple: true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Querying Collections
|
||||
|
||||
Use the provided query functions to fetch content. These follow Astro's live collections pattern, returning structured results:
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
// Get all entries - returns { entries, error }
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
|
||||
// Filter by status
|
||||
const { entries: drafts } = await getEmDashCollection("posts", {
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
// Limit results
|
||||
const { entries: recent } = await getEmDashCollection("posts", {
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
// Filter by taxonomy
|
||||
const { entries: newsPosts } = await getEmDashCollection("posts", {
|
||||
where: { category: "news" },
|
||||
});
|
||||
|
||||
// Get a single entry by slug - returns { entry, error, isPreview }
|
||||
const { entry: post } = await getEmDashEntry("posts", "my-post-slug");
|
||||
|
||||
// Handle errors
|
||||
const { entries, error } = await getEmDashCollection("posts");
|
||||
if (error) {
|
||||
console.error("Failed to load posts:", error);
|
||||
}
|
||||
```
|
||||
|
||||
## Type Generation
|
||||
|
||||
Run `npx emdash types` to generate TypeScript types from your schema:
|
||||
|
||||
```ts
|
||||
// .emdash/types.ts (generated)
|
||||
export interface Post {
|
||||
title: string;
|
||||
content: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
featuredImage?: string;
|
||||
author: string; // reference ID
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
title: string;
|
||||
price: number;
|
||||
description: PortableTextBlock[];
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Re-run `emdash types` after modifying collections in the admin panel to keep types in sync.
|
||||
</Aside>
|
||||
|
||||
## Database Mapping
|
||||
|
||||
Field types map to SQLite column types:
|
||||
|
||||
| Field Type | SQLite Type | Notes |
|
||||
| -------------- | ----------- | --------------------- |
|
||||
| `string` | `TEXT` | |
|
||||
| `text` | `TEXT` | |
|
||||
| `slug` | `TEXT` | |
|
||||
| `number` | `REAL` | 64-bit floating point |
|
||||
| `integer` | `INTEGER` | 64-bit signed integer |
|
||||
| `boolean` | `INTEGER` | 0 or 1 |
|
||||
| `datetime` | `TEXT` | ISO 8601 format |
|
||||
| `select` | `TEXT` | |
|
||||
| `multiSelect` | `JSON` | Array of strings |
|
||||
| `portableText` | `JSON` | Block array |
|
||||
| `image` | `TEXT` | Media ID |
|
||||
| `file` | `TEXT` | Media ID |
|
||||
| `reference` | `TEXT` | Entry ID |
|
||||
| `json` | `JSON` | Arbitrary JSON |
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Content Model" icon="open-book">
|
||||
Understand the [database-first approach](/concepts/content-model/).
|
||||
</Card>
|
||||
<Card title="Taxonomies" icon="list-format">
|
||||
Organize content with [categories and tags](/guides/taxonomies/).
|
||||
</Card>
|
||||
<Card title="Media Library" icon="seti:image">
|
||||
Manage [images and files](/guides/media-library/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
335
docs/src/content/docs/concepts/content-model.mdx
Normal file
@@ -0,0 +1,335 @@
|
||||
---
|
||||
title: Content Model
|
||||
description: EmDash's database-first content model—how schema is stored, modified at runtime, and queried.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash uses a **database-first content model** where schema definitions live in the database, not in code. This is a fundamental design choice that enables runtime schema modification and non-developer-friendly setup.
|
||||
|
||||
## Schema as Data
|
||||
|
||||
Traditional CMSs like Strapi or Keystatic require you to define schema in code:
|
||||
|
||||
```ts
|
||||
// Traditional approach - schema in code
|
||||
const posts = collection({
|
||||
fields: {
|
||||
title: text({ required: true }),
|
||||
content: richText(),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
EmDash stores this same information in database tables:
|
||||
|
||||
```sql
|
||||
-- _emdash_collections table
|
||||
INSERT INTO _emdash_collections (slug, label)
|
||||
VALUES ('posts', 'Blog Posts');
|
||||
|
||||
-- _emdash_fields table
|
||||
INSERT INTO _emdash_fields (collection_id, slug, type, required)
|
||||
VALUES
|
||||
('coll_abc', 'title', 'string', true),
|
||||
('coll_abc', 'content', 'portableText', false);
|
||||
```
|
||||
|
||||
Both approaches define the same content structure. The difference is where that structure lives and how it can be modified.
|
||||
|
||||
## Why Database-First?
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Runtime Modification" icon="pencil">
|
||||
Create and edit content types without code changes or rebuilds. Non-developers can design their
|
||||
data model through the admin UI.
|
||||
</Card>
|
||||
<Card title="Real SQL Columns" icon="seti:db">
|
||||
Unlike WordPress's EAV (Entity-Attribute-Value) model, each field gets a real column. Proper
|
||||
indexing, foreign keys, and query optimization.
|
||||
</Card>
|
||||
<Card title="Self-Documenting" icon="document">
|
||||
Database tools can inspect schema directly. No need to parse code to understand the data model.
|
||||
</Card>
|
||||
<Card title="Migration Path" icon="right-arrow">
|
||||
Export schema as JSON for version control. Import schema in new environments.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Schema Tables
|
||||
|
||||
Two system tables define your content structure:
|
||||
|
||||
### Collections Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE _emdash_collections (
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL, -- "posts", "products"
|
||||
label TEXT NOT NULL, -- "Blog Posts"
|
||||
label_singular TEXT, -- "Post"
|
||||
description TEXT,
|
||||
icon TEXT, -- Lucide icon name
|
||||
supports JSON, -- ["drafts", "revisions", "preview"]
|
||||
source TEXT, -- How it was created
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT
|
||||
);
|
||||
```
|
||||
|
||||
The `source` field tracks how the collection was created:
|
||||
|
||||
| Source | Description |
|
||||
| ------------------ | ---------------------------------- |
|
||||
| `manual` | Created via admin UI |
|
||||
| `template:blog` | Created by a template's seed file |
|
||||
| `import:wordpress` | Imported from WordPress |
|
||||
| `discovered` | Auto-discovered from existing data |
|
||||
|
||||
### Fields Table
|
||||
|
||||
```sql
|
||||
CREATE TABLE _emdash_fields (
|
||||
id TEXT PRIMARY KEY,
|
||||
collection_id TEXT REFERENCES _emdash_collections(id),
|
||||
slug TEXT NOT NULL, -- Column name: "title", "price"
|
||||
label TEXT NOT NULL, -- Display label
|
||||
type TEXT NOT NULL, -- Field type
|
||||
column_type TEXT NOT NULL, -- SQLite type: TEXT, REAL, INTEGER, JSON
|
||||
required INTEGER DEFAULT 0,
|
||||
unique_field INTEGER DEFAULT 0,
|
||||
default_value TEXT, -- JSON-encoded default
|
||||
validation JSON, -- Validation rules
|
||||
widget TEXT, -- Custom widget identifier
|
||||
options JSON, -- Widget options
|
||||
sort_order INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(collection_id, slug)
|
||||
);
|
||||
```
|
||||
|
||||
## Content Tables
|
||||
|
||||
Each collection gets its own table with the `ec_` prefix. When you create a "products" collection with title and price fields:
|
||||
|
||||
```sql
|
||||
CREATE TABLE ec_products (
|
||||
-- System columns (always present)
|
||||
id TEXT PRIMARY KEY,
|
||||
slug TEXT UNIQUE,
|
||||
status TEXT DEFAULT 'draft',
|
||||
author_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT,
|
||||
deleted_at TEXT, -- Soft delete
|
||||
version INTEGER DEFAULT 1, -- Optimistic locking
|
||||
|
||||
-- Content columns (from field definitions)
|
||||
title TEXT NOT NULL,
|
||||
price REAL
|
||||
);
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
System columns are automatically added to every content table. You don't define them as
|
||||
fields—they're always present.
|
||||
</Aside>
|
||||
|
||||
## Runtime Schema Changes
|
||||
|
||||
When you add a field via the admin UI, EmDash:
|
||||
|
||||
<Steps>
|
||||
1. Inserts a record into `_emdash_fields` 2. Runs `ALTER TABLE ec_collection ADD COLUMN
|
||||
column_name TYPE` 3. Regenerates the Zod schema for validation
|
||||
</Steps>
|
||||
|
||||
SQLite supports these `ALTER TABLE` operations at runtime:
|
||||
|
||||
| Operation | Supported |
|
||||
| ------------------ | --------------------------- |
|
||||
| Add column | Yes |
|
||||
| Rename column | Yes |
|
||||
| Drop column | Yes (SQLite 3.35+) |
|
||||
| Change column type | No (requires table rebuild) |
|
||||
|
||||
For type changes, EmDash handles the table rebuild transparently: create new table → copy data → drop old table → rename new table.
|
||||
|
||||
## Schema vs. Content Separation
|
||||
|
||||
EmDash maintains a clear separation:
|
||||
|
||||
| Concern | Location | Tables |
|
||||
| ------------ | ------------------------ | ------------------------------------------- |
|
||||
| **Schema** | System tables | `_emdash_collections`, `_emdash_fields` |
|
||||
| **Content** | Per-collection tables | `ec_posts`, `ec_products`, etc. |
|
||||
| **Media** | Separate table + storage | `media` table + R2/S3 |
|
||||
| **Settings** | Options table | `options` with `site:` prefix |
|
||||
|
||||
This separation means:
|
||||
|
||||
- Schema can be exported without content
|
||||
- Content can be migrated between schemas
|
||||
- System tables are never cluttered with user data
|
||||
|
||||
## Validation at Runtime
|
||||
|
||||
EmDash builds Zod schemas from database field definitions at startup:
|
||||
|
||||
```ts
|
||||
// Simplified example
|
||||
function buildSchema(fields: Field[]): ZodSchema {
|
||||
const shape: Record<string, ZodType> = {};
|
||||
|
||||
for (const field of fields) {
|
||||
let zodType = fieldTypeToZod(field.type);
|
||||
|
||||
if (field.required) {
|
||||
zodType = zodType.required();
|
||||
}
|
||||
|
||||
if (field.validation?.min !== undefined) {
|
||||
zodType = zodType.min(field.validation.min);
|
||||
}
|
||||
|
||||
shape[field.slug] = zodType;
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
```
|
||||
|
||||
Content is validated against these runtime schemas on every create and update operation.
|
||||
|
||||
## TypeScript Integration
|
||||
|
||||
Generate TypeScript types from your database schema:
|
||||
|
||||
```bash
|
||||
# Fetch schema from database, generate types
|
||||
npx emdash types
|
||||
```
|
||||
|
||||
This generates `.emdash/types.ts`:
|
||||
|
||||
```ts
|
||||
// .emdash/types.ts (generated)
|
||||
export interface Post {
|
||||
title: string;
|
||||
content: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
featuredImage?: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
title: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
}
|
||||
|
||||
// Typed overloads for query functions
|
||||
declare module "emdash" {
|
||||
export function getEmDashCollection(
|
||||
type: "posts",
|
||||
): Promise<{ entries: ContentEntry<Post>[]; error?: Error }>;
|
||||
|
||||
export function getEmDashEntry(
|
||||
type: "products",
|
||||
id: string,
|
||||
): Promise<{ entry: ContentEntry<Product> | null; error?: Error; isPreview: boolean }>;
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
Type generation is optional but recommended. It gives you autocomplete and type checking for your
|
||||
dynamically-defined collections.
|
||||
</Aside>
|
||||
|
||||
## Developer vs. Non-Developer Workflow
|
||||
|
||||
**Developers** can use the CLI:
|
||||
|
||||
```bash
|
||||
# Fetch schema, generate types
|
||||
npx emdash types
|
||||
|
||||
# Export schema as JSON
|
||||
npx emdash export-seed > seed.json
|
||||
```
|
||||
|
||||
**Non-developers** use the admin UI exclusively:
|
||||
|
||||
1. Open **Content Types** in the admin panel
|
||||
2. Click **Add Collection**
|
||||
3. Define fields through the visual builder
|
||||
4. Start creating content immediately
|
||||
|
||||
Both approaches modify the same underlying database tables.
|
||||
|
||||
## Seed Files
|
||||
|
||||
Templates and exports use JSON seed files for portable schema definitions:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1",
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Blog Posts",
|
||||
"labelSingular": "Post",
|
||||
"supports": ["drafts", "revisions", "preview"],
|
||||
"fields": [
|
||||
{ "slug": "title", "type": "string", "required": true },
|
||||
{ "slug": "content", "type": "portableText" },
|
||||
{ "slug": "featuredImage", "type": "image" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"taxonomies": [{ "name": "category", "label": "Categories", "hierarchical": true }],
|
||||
"menus": [{ "name": "primary", "label": "Primary Navigation" }]
|
||||
}
|
||||
```
|
||||
|
||||
Apply seed files programmatically:
|
||||
|
||||
```ts
|
||||
import { applySeed, validateSeed } from "emdash/seed";
|
||||
import seedData from "./.emdash/seed.json";
|
||||
|
||||
// Validate first
|
||||
const { valid, errors } = validateSeed(seedData);
|
||||
|
||||
// Apply (idempotent - safe to re-run)
|
||||
await applySeed(db, seedData, {
|
||||
includeContent: true,
|
||||
onConflict: "skip", // 'skip' | 'update' | 'error'
|
||||
});
|
||||
```
|
||||
|
||||
## Comparison with Other Approaches
|
||||
|
||||
| Approach | Schema Location | Runtime Modification | Type Safety |
|
||||
| ------------- | --------------- | ----------------------- | ------------------ |
|
||||
| **EmDash** | Database | Yes (full) | Generated from DB |
|
||||
| **WordPress** | PHP code + EAV | Limited (meta fields) | None |
|
||||
| **Strapi** | Code files | No (rebuild required) | Generated at build |
|
||||
| **Sanity** | Code files | No (schema must deploy) | Built-in |
|
||||
| **Directus** | Database | Yes (full) | Generated from DB |
|
||||
|
||||
EmDash follows the Directus model: database-first with optional type generation. This provides maximum flexibility while still supporting type-safe development when desired.
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Collections" icon="document">
|
||||
Learn about [field types and validation](/concepts/collections/).
|
||||
</Card>
|
||||
<Card title="Admin Panel" icon="setting">
|
||||
Explore the [admin architecture](/concepts/admin-panel/).
|
||||
</Card>
|
||||
<Card title="Seeding" icon="open-book">
|
||||
Set up sites with [seed files](/themes/seed-files/).
|
||||
</Card>
|
||||
</CardGrid>
|
||||
115
docs/src/content/docs/contributing/index.mdx
Normal file
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Contributing to EmDash
|
||||
description: How to set up a development environment and contribute to EmDash.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is a pnpm monorepo. The main package is `packages/core` (published as `emdash`) — it contains the Astro integration, REST API, database layer, schema management, and plugin system. The admin UI lives in `packages/admin`.
|
||||
|
||||
For the full contributor reference — repo layout, architecture, code conventions, changeset policy — see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Local Setup
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Clone and install**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/emdash-cms/emdash.git
|
||||
cd emdash
|
||||
pnpm install
|
||||
pnpm build # required before first run
|
||||
```
|
||||
|
||||
2. **Start the demo**
|
||||
|
||||
```bash
|
||||
cd demos/simple
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
The setup wizard runs automatically on first launch — it creates the database, runs migrations, and prompts you to create an admin account.
|
||||
|
||||
To populate with sample content: `pnpm seed`
|
||||
|
||||
3. **Open the admin**
|
||||
|
||||
Visit [http://localhost:4321/_emdash/admin](http://localhost:4321/_emdash/admin)
|
||||
|
||||
In dev mode, you can skip passkey auth with the bypass endpoint:
|
||||
|
||||
```
|
||||
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
|
||||
```
|
||||
|
||||
</Steps>
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Watch Mode
|
||||
|
||||
For iterating on core packages alongside the demo, run two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1 — rebuild packages/core on change
|
||||
cd packages/core && pnpm dev
|
||||
|
||||
# Terminal 2 — run the demo
|
||||
cd demos/simple && pnpm dev
|
||||
```
|
||||
|
||||
### Checks
|
||||
|
||||
Run these before committing (from the repo root):
|
||||
|
||||
```bash
|
||||
pnpm typecheck # TypeScript
|
||||
pnpm lint # full type-aware lint
|
||||
pnpm format # auto-format (oxfmt, tabs)
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Type checking and linting must both pass. Don't commit with known failures.
|
||||
</Aside>
|
||||
|
||||
### Tests
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="All tests">
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Core only">
|
||||
```bash
|
||||
cd packages/core && pnpm test
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Watch mode">
|
||||
```bash
|
||||
cd packages/core && pnpm test --watch
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="E2E">
|
||||
```bash
|
||||
pnpm test:e2e # starts its own server
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.
|
||||
|
||||
## What We Accept
|
||||
|
||||
| Type | Process |
|
||||
| ---------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **Bug fixes** | Open a PR directly. Include a failing test. |
|
||||
| **Docs / typos** | Open a PR directly. |
|
||||
| **Translations** | Open a PR directly. See [Translating EmDash](/contributing/translating/). |
|
||||
| **Features** | Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) and wait for a maintainer to approve it. |
|
||||
| **Refactors** | Open a Discussion first. |
|
||||
|
||||
Feature PRs without prior maintainer approval will be closed.
|
||||
|
||||
For the full contribution policy, changeset guide, repo layout, and architecture overview, see [CONTRIBUTING.md](https://github.com/emdash-cms/emdash/blob/main/CONTRIBUTING.md).
|
||||
191
docs/src/content/docs/contributing/translating.mdx
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Translating EmDash
|
||||
description: How to contribute translations for the EmDash admin UI.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's admin UI is translatable using [Lingui](https://lingui.dev) for message extraction and [Lunaria](https://lunaria.dev) for tracking translation progress. All translations live in PO (gettext) files — one per locale.
|
||||
|
||||
## Translation status
|
||||
|
||||
See the [translation dashboard](https://i18n.emdashcms.com) for current progress across all locales.
|
||||
|
||||
## Who can translate
|
||||
|
||||
Translations must come from **native or fluent speakers**. We don't accept machine-generated translations. If you use AI tools to assist, you must review every string by hand and test the result in context (see [Testing your translations](#testing-your-translations) below).
|
||||
|
||||
We'd rather have no translation for a string than a bad one. A wrong translation is worse than showing the English fallback — it actively misleads users.
|
||||
|
||||
## File structure
|
||||
|
||||
Translation catalogs live in `packages/admin/src/locales/`:
|
||||
|
||||
```
|
||||
packages/admin/src/locales/
|
||||
├── en/
|
||||
│ └── messages.po # English (source)
|
||||
├── de/
|
||||
│ └── messages.po # German
|
||||
└── ...
|
||||
```
|
||||
|
||||
Each `.po` file contains `msgid`/`msgstr` pairs. The `msgid` is the English source text; the `msgstr` is your translation. Empty `msgstr` means "not yet translated" — Lingui will fall back to English at runtime.
|
||||
|
||||
## Translating strings
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Check the [translation dashboard](https://i18n.emdashcms.com)** to see what needs work. Check open PRs to avoid duplicating effort.
|
||||
|
||||
2. **Fork the repo and create a branch:**
|
||||
|
||||
```bash
|
||||
git checkout -b i18n/de
|
||||
```
|
||||
|
||||
3. **Open your locale's PO file** (e.g., `packages/admin/src/locales/de/messages.po`).
|
||||
|
||||
4. **Fill in translations.** Each entry looks like this:
|
||||
|
||||
```po
|
||||
#: packages/admin/src/components/LoginPage.tsx:304
|
||||
msgid "Sign in with Passkey"
|
||||
msgstr ""
|
||||
```
|
||||
|
||||
Fill in the `msgstr`:
|
||||
|
||||
```po
|
||||
#: packages/admin/src/components/LoginPage.tsx:304
|
||||
msgid "Sign in with Passkey"
|
||||
msgstr "Mit Passkey anmelden"
|
||||
```
|
||||
|
||||
5. **Test your translations** (see below).
|
||||
|
||||
6. **Open a PR** targeting `main`. Title format: `i18n(de): add/update German translations`.
|
||||
|
||||
</Steps>
|
||||
|
||||
### What to translate
|
||||
|
||||
- The `msgstr` value for each entry.
|
||||
|
||||
### What NOT to translate
|
||||
|
||||
- `msgid` values — these are lookup keys.
|
||||
- Interpolation placeholders like `{error}`, `{email}`, `{label}` — keep them exactly as-is.
|
||||
- XML-style tags like `<0>`, `</0>` — these wrap interactive elements (links, buttons). Keep the tags and translate the text between them.
|
||||
- Comments starting with `#:` — these are source references added by Lingui.
|
||||
|
||||
### Interpolation and tags
|
||||
|
||||
Some strings contain placeholders and tags:
|
||||
|
||||
```po
|
||||
msgid "Authentication error: {error}"
|
||||
msgstr "Authentifizierungsfehler: {error}"
|
||||
|
||||
msgid "Don't have an account? <0>Sign up</0>"
|
||||
msgstr "Noch kein Konto? <0>Registrieren</0>"
|
||||
|
||||
msgid "If an account exists for <0>{email}</0>, we've sent a sign-in link."
|
||||
msgstr "Falls ein Konto für <0>{email}</0> existiert, haben wir einen Anmeldelink gesendet."
|
||||
```
|
||||
|
||||
Placeholders (`{error}`, `{email}`) are replaced with dynamic values at runtime. Tags (`<0>...</0>`) wrap React components. Both must appear in your translation exactly as they appear in the source — same names, same nesting.
|
||||
|
||||
## Testing your translations
|
||||
|
||||
<Aside type="caution">
|
||||
Always test your translations in the running admin UI. Strings that read well in isolation can look wrong in context — truncated buttons, broken layouts, or awkward phrasing that only shows up when you see the actual screen.
|
||||
</Aside>
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Compile and run the demo:**
|
||||
|
||||
```bash
|
||||
pnpm run locale:compile
|
||||
pnpm build
|
||||
pnpm --filter emdash-demo dev
|
||||
```
|
||||
|
||||
2. **Switch locale** in the admin Settings page and verify your translations look correct in context.
|
||||
|
||||
</Steps>
|
||||
|
||||
### Pseudo locale
|
||||
|
||||
EmDash ships a **pseudo locale** that garbles all wrapped strings into accented lookalikes — `"Dashboard"` becomes `"Ðàšĥƀöàřð"`, and so on. Any string that appears in normal English while the pseudo locale is active is either missing a `t\`...\`` wrapper or is coming from outside the catalog.
|
||||
|
||||
To enable it, add the following to your `.env` file in the demo directory:
|
||||
|
||||
```ini title="demos/simple/.env"
|
||||
EMDASH_PSEUDO_LOCALE=1
|
||||
```
|
||||
|
||||
Then restart the dev server. The pseudo locale appears as **Pseudo** in the language picker on the login page and in Settings. Switch to it to spot unwrapped strings at a glance.
|
||||
|
||||
<Aside>
|
||||
The pseudo locale is only available in development (`import.meta.env.DEV`). It is never exposed in production builds regardless of environment variables.
|
||||
</Aside>
|
||||
|
||||
## Adding a new language
|
||||
|
||||
If your language doesn't have a PO file yet:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Add the locale to `packages/admin/src/locales/locales.ts`:**
|
||||
|
||||
```ts
|
||||
export const LOCALES: LocaleDefinition[] = [
|
||||
{ code: "en", label: "English", enabled: true },
|
||||
{ code: "de", label: "Deutsch", enabled: true },
|
||||
// ...
|
||||
{ code: "ja", label: "日本語", enabled: false }, // add yours
|
||||
];
|
||||
```
|
||||
|
||||
This is the single source of truth — `lingui.config.ts`, `lunaria.config.ts` and the admin runtime all derive their locale lists from this file. Set `enabled: false` unless your translations have 100% coverage — we'll enable it once the translation reaches sufficient coverage.
|
||||
|
||||
2. **Run extraction** to generate the empty PO file:
|
||||
|
||||
```bash
|
||||
pnpm run locale:extract
|
||||
```
|
||||
|
||||
This creates `packages/admin/src/locales/{your-locale}/messages.po` with all strings ready to translate.
|
||||
|
||||
3. **Translate and test** following the steps above.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Translation standards
|
||||
|
||||
### Accuracy
|
||||
|
||||
Translations should faithfully represent the English source text at a native speaker's level. Don't add, remove, or reinterpret meaning. If a source string is ambiguous, check the `#:` comment for the source file location — read the component code to understand the context.
|
||||
|
||||
### Consistency
|
||||
|
||||
Use consistent terminology within your locale. If you translate "collection" as "Sammlung" in one place, don't switch to "Kollektion" elsewhere. If your language already has translations, read through the existing PO file before starting to match the established terminology.
|
||||
|
||||
### Tone
|
||||
|
||||
The admin UI uses a direct, professional tone. Match that in your language — avoid overly formal or overly casual phrasing.
|
||||
|
||||
### AI-assisted translations
|
||||
|
||||
You may use AI tools to draft translations, but:
|
||||
|
||||
- You **must** review every string yourself. AI tools make subtle errors that only a fluent speaker would catch — wrong register, unnatural phrasing, incorrect technical terms.
|
||||
- You **must** test the result in the running admin UI. AI tools have no awareness of layout constraints or UI context.
|
||||
- Disclose AI usage in your PR description.
|
||||
- PRs with obvious unreviewed machine translations will be closed.
|
||||
|
||||
## Partial translations
|
||||
|
||||
Partial translations are welcome. You don't need to translate every string in one PR — any progress helps. Untranslated strings will fall back to English at runtime.
|
||||
278
docs/src/content/docs/deployment/cloudflare.mdx
Normal file
@@ -0,0 +1,278 @@
|
||||
---
|
||||
title: Deploy to Cloudflare
|
||||
description: Deploy EmDash to Cloudflare Workers with D1 and R2.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Cloudflare Workers provides a fast, globally distributed runtime for EmDash. This guide covers deploying with D1 for the database and R2 for media storage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Cloudflare account
|
||||
- Wrangler CLI installed (`npm install -g wrangler`)
|
||||
- Authenticated with Cloudflare (`wrangler login`)
|
||||
|
||||
## Create Resources
|
||||
|
||||
### 1. Create a D1 database
|
||||
|
||||
```bash
|
||||
wrangler d1 create emdash-db
|
||||
```
|
||||
|
||||
Note the `database_id` from the output.
|
||||
|
||||
### 2. Create an R2 bucket
|
||||
|
||||
```bash
|
||||
wrangler r2 bucket create emdash-media
|
||||
```
|
||||
|
||||
### 3. Create wrangler.jsonc
|
||||
|
||||
Create `wrangler.jsonc` in your project root:
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "my-emdash-site",
|
||||
"compatibility_date": "2025-01-15",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash-db",
|
||||
"database_id": "your-database-id",
|
||||
},
|
||||
],
|
||||
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "emdash-media",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Configure EmDash
|
||||
|
||||
Update your Astro configuration to use D1 and R2:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import emdash from "emdash/astro";
|
||||
import { d1, r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: cloudflare(),
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
D1 migrations must be run via Wrangler CLI before deployment. DDL statements are not allowed at
|
||||
runtime.
|
||||
</Aside>
|
||||
|
||||
## Run Migrations
|
||||
|
||||
Generate and apply the database schema.
|
||||
|
||||
### 1. Export the schema SQL
|
||||
|
||||
```bash
|
||||
npx emdash init --database ./data.db
|
||||
```
|
||||
|
||||
### 2. Apply migrations to D1
|
||||
|
||||
```bash
|
||||
wrangler d1 migrations apply emdash-db
|
||||
```
|
||||
|
||||
If you don't have migration files, apply the core schema directly:
|
||||
|
||||
```bash
|
||||
wrangler d1 execute emdash-db --file=./node_modules/emdash/migrations/0001_core.sql
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
Deploy to Cloudflare Workers:
|
||||
|
||||
```bash
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
Your site is now live at `https://my-emdash-site.<your-subdomain>.workers.dev`.
|
||||
|
||||
## Read Replicas
|
||||
|
||||
For globally distributed sites, enable D1 read replication to route read queries to nearby replicas instead of always hitting the primary database. This significantly reduces latency for visitors far from the primary region.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
database: d1({
|
||||
binding: "DB",
|
||||
session: "auto",
|
||||
}),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
}),
|
||||
```
|
||||
|
||||
You also need to enable read replication on the D1 database itself in the Cloudflare dashboard or via the REST API.
|
||||
|
||||
See [Database Options — Read Replicas](/deployment/database/#read-replicas) for session modes and how bookmark-based consistency works.
|
||||
|
||||
## Custom Domain
|
||||
|
||||
Add a custom domain in the Cloudflare dashboard:
|
||||
|
||||
1. Go to **Workers & Pages** > your worker
|
||||
2. Click **Custom Domains** > **Add Custom Domain**
|
||||
3. Enter your domain and follow the DNS setup instructions
|
||||
|
||||
## Public R2 Access
|
||||
|
||||
To serve media directly from R2 (recommended for performance):
|
||||
|
||||
1. In the Cloudflare dashboard, go to **R2** > your bucket
|
||||
2. Click **Settings** > **Public access**
|
||||
3. Enable public access and note the public URL
|
||||
4. Update your storage config:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
storage: r2({
|
||||
binding: "MEDIA",
|
||||
publicUrl: "https://pub-xxx.r2.dev"
|
||||
}),
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
For production, connect a custom domain to your R2 bucket for better URLs and caching control.
|
||||
</Aside>
|
||||
|
||||
## Cloudflare Access Authentication
|
||||
|
||||
If your organization uses Cloudflare Access, you can use it as your authentication provider instead of passkeys. This provides SSO with your existing identity provider.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "your-app-audience-tag",
|
||||
roleMapping: {
|
||||
"Admins": 50,
|
||||
"Editors": 40,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
```
|
||||
|
||||
See the [Authentication guide](/guides/authentication#cloudflare-access) for full configuration options.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Recommended: encryption key
|
||||
|
||||
`EMDASH_ENCRYPTION_KEY` is used to encrypt plugin secrets at rest
|
||||
(webhook tokens, Turnstile keys, etc.). The current release validates
|
||||
the key but does not yet use it — plugin secret encryption is rolling
|
||||
out and will start using this value automatically when available. Set
|
||||
it now so your deployment is ready.
|
||||
|
||||
The key is provided by you and never stored in the database; only
|
||||
encrypted ciphertext is. Losing it means losing every secret encrypted
|
||||
with it.
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
wrangler secret put EMDASH_ENCRYPTION_KEY
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Never commit the encryption key to your repository, and back it up
|
||||
somewhere durable (a password manager, KMS, or your team's secret
|
||||
store). The plain text value lives only in your environment.
|
||||
</Aside>
|
||||
|
||||
### Optional: stable-value overrides
|
||||
|
||||
EmDash auto-generates the preview HMAC secret and commenter-IP hash
|
||||
salt and persists them in the database on first use. The env vars
|
||||
below are overrides for cases where you need to pin the value
|
||||
yourself — for example, when a preview Worker in a separate process
|
||||
needs to share the secret with your main site.
|
||||
|
||||
| Variable | Purpose |
|
||||
| -------- | ------- |
|
||||
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
|
||||
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
|
||||
| `EMDASH_AUTH_SECRET` | Legacy. No longer required. If set, it's used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which wins) so existing installs keep stable commenter-IP hashes across the upgrade. New installs should ignore this. |
|
||||
|
||||
Access environment variables in your configuration using `import.meta.env` or the Cloudflare `env` binding.
|
||||
|
||||
## Preview Deployments
|
||||
|
||||
Deploy a preview branch:
|
||||
|
||||
```bash
|
||||
wrangler deploy --env preview
|
||||
```
|
||||
|
||||
Add an environment section to `wrangler.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"env": {
|
||||
"preview": {
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash-db-preview",
|
||||
"database_id": "your-preview-db-id",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "D1 binding not found"
|
||||
|
||||
Verify the binding name in `wrangler.jsonc` matches your database configuration:
|
||||
|
||||
```js
|
||||
// Must match: d1({ binding: "DB" })
|
||||
"binding": "DB"
|
||||
```
|
||||
|
||||
### "R2 binding not found"
|
||||
|
||||
Check that the R2 bucket is correctly bound:
|
||||
|
||||
```js
|
||||
// Must match: r2({ binding: "MEDIA" })
|
||||
"binding": "MEDIA"
|
||||
```
|
||||
|
||||
### Migration errors
|
||||
|
||||
D1 migrations run via Wrangler, not at runtime. If you see schema errors:
|
||||
|
||||
1. Check that migrations were applied: `wrangler d1 migrations list emdash-db`
|
||||
2. Re-apply if needed: `wrangler d1 migrations apply emdash-db`
|
||||
320
docs/src/content/docs/deployment/database.mdx
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: Database Options
|
||||
description: Configure EmDash with D1, PostgreSQL, libSQL, or SQLite.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash supports multiple database backends. Choose based on your deployment target.
|
||||
|
||||
## Overview
|
||||
|
||||
| Database | Best For | Deployment |
|
||||
| -------------- | ------------------ | -------------------------- |
|
||||
| **D1** | Cloudflare Workers | Edge, globally distributed |
|
||||
| **PostgreSQL** | Production Node.js | Any platform with Postgres |
|
||||
| **libSQL** | Remote databases | Edge or Node.js |
|
||||
| **SQLite** | Node.js, local dev | Single server |
|
||||
|
||||
## Cloudflare D1
|
||||
|
||||
D1 is Cloudflare's serverless SQLite database. Use it when deploying to Cloudflare Workers.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { d1 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | -------- | -------------------- | ------------------------------------- |
|
||||
| `binding` | `string` | — | D1 binding name from `wrangler.jsonc` |
|
||||
| `session` | `string` | `"disabled"` | Read replication mode (see below) |
|
||||
| `bookmarkCookie` | `string` | `"__em_d1_bookmark"` | Cookie name for session bookmarks |
|
||||
|
||||
### Setup
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="wrangler.jsonc">
|
||||
```jsonc
|
||||
{
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash-db",
|
||||
"database_id": "your-database-id"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="wrangler.toml">
|
||||
```toml
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "emdash-db"
|
||||
database_id = "your-database-id"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside type="caution">
|
||||
D1 does not allow DDL statements at runtime. Run migrations via Wrangler CLI before deployment:
|
||||
```bash wrangler d1 migrations apply emdash-db ```
|
||||
</Aside>
|
||||
|
||||
### Create a D1 Database
|
||||
|
||||
```bash
|
||||
wrangler d1 create emdash-db
|
||||
```
|
||||
|
||||
### Read Replicas
|
||||
|
||||
D1 supports [read replication](https://developers.cloudflare.com/d1/configuration/read-replication/) to lower read latency for globally distributed sites. When enabled, read queries are routed to nearby replicas instead of always hitting the primary database.
|
||||
|
||||
EmDash uses the D1 Sessions API to manage this transparently. Enable it with the `session` option:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { d1 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({
|
||||
binding: "DB",
|
||||
session: "auto",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
#### Session Modes
|
||||
|
||||
| Mode | Behavior |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| `"disabled"` | No sessions. All queries go to primary. Default. |
|
||||
| `"auto"` | Anonymous requests read from the nearest replica. Authenticated users get read-your-writes consistency via bookmark cookies. |
|
||||
| `"primary-first"`| Like `"auto"`, but the first query always goes to the primary. Use for sites with very frequent writes. |
|
||||
|
||||
#### How It Works
|
||||
|
||||
- **Anonymous visitors** get `first-unconstrained` — reads go to the nearest replica for the lowest latency. Since anonymous users never write, they don't need consistency guarantees.
|
||||
- **Authenticated users** (editors, authors) get bookmark-based sessions. After a write, a bookmark cookie ensures the next request sees at least that state.
|
||||
- **Write requests** (`POST`, `PUT`, `DELETE`) always start at the primary database.
|
||||
- **Build-time queries** (Astro content collections) bypass sessions entirely and use the primary directly.
|
||||
|
||||
<Aside type="tip">
|
||||
Read replication must also be enabled on the D1 database itself via the Cloudflare dashboard or
|
||||
REST API. The `session` option only controls EmDash's client-side session management.
|
||||
</Aside>
|
||||
|
||||
<Aside>
|
||||
Anonymous visitors may see data that is a few seconds stale. For a CMS this is expected — content
|
||||
changes are not real-time, and if you have route caching enabled the response is already stale by
|
||||
definition.
|
||||
</Aside>
|
||||
|
||||
## libSQL
|
||||
|
||||
libSQL is a fork of SQLite that supports remote connections. Use it when you need a remote database without Cloudflare D1.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { libsql } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: libsql({
|
||||
url: process.env.LIBSQL_DATABASE_URL,
|
||||
authToken: process.env.LIBSQL_AUTH_TOKEN,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | ---------------------------------------------------- |
|
||||
| `url` | `string` | Database URL (`libsql://...` or `file:...`) |
|
||||
| `authToken` | `string` | Auth token for remote databases (optional for local) |
|
||||
|
||||
### Local Development
|
||||
|
||||
Use a local libSQL file during development:
|
||||
|
||||
```js
|
||||
database: libsql({ url: "file:./data.db" });
|
||||
```
|
||||
|
||||
## PostgreSQL
|
||||
|
||||
PostgreSQL is supported for Node.js deployments that need a full relational database.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { postgres } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: postgres({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
You can connect with a connection string or individual parameters:
|
||||
|
||||
```js
|
||||
// Connection string
|
||||
database: postgres({
|
||||
connectionString: "postgres://user:password@localhost:5432/emdash",
|
||||
});
|
||||
|
||||
// Individual parameters
|
||||
database: postgres({
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "emdash",
|
||||
user: "emdash",
|
||||
password: process.env.DB_PASSWORD,
|
||||
ssl: true,
|
||||
});
|
||||
```
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------ | --------- | ------------------------------------ |
|
||||
| `connectionString` | `string` | PostgreSQL connection URL |
|
||||
| `host` | `string` | Database host |
|
||||
| `port` | `number` | Database port |
|
||||
| `database` | `string` | Database name |
|
||||
| `user` | `string` | Database user |
|
||||
| `password` | `string` | Database password |
|
||||
| `ssl` | `boolean` | Enable SSL |
|
||||
| `pool.min` | `number` | Minimum pool connections (default 0) |
|
||||
| `pool.max` | `number` | Maximum pool connections (default 10)|
|
||||
|
||||
### Connection Pooling
|
||||
|
||||
The adapter uses `pg.Pool` under the hood. Tune pool size based on your deployment:
|
||||
|
||||
```js
|
||||
database: postgres({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
pool: { min: 2, max: 20 },
|
||||
});
|
||||
```
|
||||
|
||||
<Aside>
|
||||
PostgreSQL requires the `pg` package. Install it as a dependency:
|
||||
```bash
|
||||
pnpm add pg
|
||||
```
|
||||
</Aside>
|
||||
|
||||
## SQLite
|
||||
|
||||
SQLite with better-sqlite3 is the simplest option for Node.js deployments.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | -------- | ----------------------------- |
|
||||
| `url` | `string` | File path with `file:` prefix |
|
||||
|
||||
### File Path
|
||||
|
||||
The `url` must start with `file:`:
|
||||
|
||||
```js
|
||||
// Relative path
|
||||
database: sqlite({ url: "file:./data/emdash.db" });
|
||||
|
||||
// Absolute path
|
||||
database: sqlite({ url: "file:/var/data/emdash.db" });
|
||||
|
||||
// From environment variable
|
||||
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` });
|
||||
```
|
||||
|
||||
<Aside>
|
||||
SQLite requires a persistent filesystem. It does not work on platforms with ephemeral storage
|
||||
without additional configuration.
|
||||
</Aside>
|
||||
|
||||
## Migrations
|
||||
|
||||
EmDash handles migrations automatically for SQLite, libSQL, and PostgreSQL. For D1, run migrations via Wrangler.
|
||||
|
||||
### Check Migration Status
|
||||
|
||||
```bash
|
||||
npx emdash init --database ./data.db
|
||||
```
|
||||
|
||||
This command:
|
||||
|
||||
1. Creates the database file if needed
|
||||
2. Runs any pending migrations
|
||||
3. Reports the current migration status
|
||||
|
||||
### Migration Files
|
||||
|
||||
Migrations are bundled with EmDash. To run them manually:
|
||||
|
||||
```bash
|
||||
# SQLite/libSQL - migrations run automatically
|
||||
|
||||
# D1 - run via wrangler
|
||||
wrangler d1 migrations apply DB
|
||||
```
|
||||
|
||||
## Environment-Based Configuration
|
||||
|
||||
Use different databases per environment:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { sqlite, libsql, postgres } from "emdash/db";
|
||||
import { d1 } from "@emdash-cms/cloudflare";
|
||||
|
||||
const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [emdash({ database })],
|
||||
});
|
||||
```
|
||||
|
||||
Or switch based on environment variables:
|
||||
|
||||
```js
|
||||
const database = process.env.DATABASE_URL
|
||||
? postgres({ connectionString: process.env.DATABASE_URL })
|
||||
: sqlite({ url: "file:./data.db" });
|
||||
```
|
||||
223
docs/src/content/docs/deployment/nodejs.mdx
Normal file
@@ -0,0 +1,223 @@
|
||||
---
|
||||
title: Deploy to Node.js
|
||||
description: Deploy EmDash to any Node.js hosting platform.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash runs on any Node.js 22+ hosting platform. This guide covers deployment to common providers using SQLite and local or S3-compatible storage.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js v22.12.0 or higher
|
||||
- A Node.js hosting provider or VPS
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure EmDash for Node.js deployment:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import node from "@astrojs/node";
|
||||
import emdash, { local, s3 } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: node({ mode: "standalone" }),
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data/emdash.db" }),
|
||||
storage: local({
|
||||
directory: "./data/uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Build and Run
|
||||
|
||||
1. Build the project:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. Initialize the database:
|
||||
|
||||
```bash
|
||||
npx emdash init --database ./data/emdash.db
|
||||
```
|
||||
|
||||
3. Start the server:
|
||||
|
||||
```bash
|
||||
node ./dist/server/entry.mjs
|
||||
```
|
||||
|
||||
The server runs on `http://localhost:4321` by default.
|
||||
|
||||
## Production Storage
|
||||
|
||||
For production, use S3-compatible storage instead of local filesystem:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash, { s3 } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: `file:${process.env.DATABASE_PATH}` }),
|
||||
storage: s3({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Aside>
|
||||
S3-compatible storage works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
|
||||
</Aside>
|
||||
|
||||
## Docker
|
||||
|
||||
Add a `.dockerignore` to keep the build context small:
|
||||
|
||||
```plain title=".dockerignore"
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
```
|
||||
|
||||
Create a `Dockerfile`:
|
||||
|
||||
```dockerfile title="Dockerfile"
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/dist ./dist
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/seed ./seed
|
||||
COPY --from=builder /app/astro.config.mjs ./
|
||||
|
||||
RUN mkdir -p data
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
|
||||
EXPOSE 4321
|
||||
CMD ["sh", "-c", "npx emdash init && node ./dist/server/entry.mjs"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
|
||||
```bash
|
||||
docker build -t my-emdash-site .
|
||||
docker run -p 4321:4321 -v emdash-data:/app/data my-emdash-site
|
||||
```
|
||||
|
||||
Or use Docker Compose:
|
||||
|
||||
```yaml title="compose.yaml"
|
||||
services:
|
||||
emdash:
|
||||
build: .
|
||||
ports:
|
||||
- "4321:4321"
|
||||
volumes:
|
||||
- emdash-data:/app/data
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
emdash-data:
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Recommended: encryption key
|
||||
|
||||
`EMDASH_ENCRYPTION_KEY` is used to encrypt plugin secrets at rest. The
|
||||
current release validates the key but does not yet use it — plugin
|
||||
secret encryption is rolling out and will start using this value
|
||||
automatically when available. Set it now so your deployment is ready.
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate # add the result to your environment
|
||||
```
|
||||
|
||||
The key is provided by you and never stored in the database; only
|
||||
encrypted ciphertext is. Back it up somewhere durable (a password
|
||||
manager, KMS, or your team's secret store) — losing it means losing
|
||||
every secret encrypted with it.
|
||||
|
||||
### Optional: stable-value overrides
|
||||
|
||||
EmDash auto-generates the preview HMAC secret and commenter-IP hash
|
||||
salt and persists them in the database on first use. The env vars
|
||||
below pin them to a value you control — useful when a separate
|
||||
process needs to share a secret with your main site.
|
||||
|
||||
| Variable | Description |
|
||||
| -------- | ----------- |
|
||||
| `EMDASH_PREVIEW_SECRET` | Override for the auto-generated preview HMAC secret. |
|
||||
| `EMDASH_IP_SALT` | Override for the auto-generated commenter-IP hash salt. |
|
||||
| `EMDASH_AUTH_SECRET` | Legacy. No longer required. If set, used as the IP-salt source (unless `EMDASH_IP_SALT` is also set, which wins) so existing installs keep stable commenter-IP hashes across the upgrade. New installs should ignore this. |
|
||||
|
||||
### Database and Storage
|
||||
|
||||
| Variable | Description | Example |
|
||||
| ---------------------- | ----------------------- | -------------------------- |
|
||||
| `DATABASE_PATH` | Path to SQLite database | `/data/emdash.db` |
|
||||
| `HOST` | Server host | `0.0.0.0` |
|
||||
| `PORT` | Server port | `4321` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_BUCKET` | S3 bucket name | `my-media-bucket` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | `AKIA...` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | `...` |
|
||||
| `S3_PUBLIC_URL` | Public URL for media | `https://cdn.example.com` |
|
||||
|
||||
<Aside type="caution">
|
||||
Never commit secrets to your repository. Use your platform's secret management (environment variables, secret stores, etc.) for production.
|
||||
</Aside>
|
||||
|
||||
## Persistent Storage
|
||||
|
||||
SQLite requires persistent disk storage. Ensure your hosting platform provides:
|
||||
|
||||
- A mounted volume or persistent disk
|
||||
- Write access to the database directory
|
||||
- Backup mechanisms for the database file
|
||||
|
||||
<Aside type="caution">
|
||||
Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.
|
||||
</Aside>
|
||||
|
||||
## Health Checks
|
||||
|
||||
Add a health check endpoint for load balancers:
|
||||
|
||||
```astro title="src/pages/health.ts"
|
||||
export const GET = () => {
|
||||
return new Response("OK", { status: 200 });
|
||||
};
|
||||
```
|
||||
|
||||
Configure your platform to check `/health` for liveness probes.
|
||||
310
docs/src/content/docs/deployment/storage.mdx
Normal file
@@ -0,0 +1,310 @@
|
||||
---
|
||||
title: Storage Options
|
||||
description: Configure media storage with R2, S3, or local filesystem.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash stores uploaded media (images, documents, videos) in a configurable storage backend. Choose based on your deployment platform and requirements.
|
||||
|
||||
## Overview
|
||||
|
||||
| Storage | Best For | Features |
|
||||
| -------------- | ------------------ | --------------------------- |
|
||||
| **R2 Binding** | Cloudflare Workers | Zero-config, fast |
|
||||
| **S3** | Any platform | Signed uploads, CDN support |
|
||||
| **Local** | Development | Simple filesystem storage |
|
||||
|
||||
## Cloudflare R2 (Binding)
|
||||
|
||||
Use R2 bindings when deploying to Cloudflare Workers for the fastest integration.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash from "emdash/astro";
|
||||
import { r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | ------------------------------------- |
|
||||
| `binding` | `string` | R2 binding name from `wrangler.jsonc` |
|
||||
| `publicUrl` | `string` | Optional public URL for the bucket |
|
||||
|
||||
### Setup
|
||||
|
||||
Add the R2 binding to your Wrangler configuration:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="wrangler.jsonc">
|
||||
```jsonc
|
||||
{
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "emdash-media"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="wrangler.toml">
|
||||
```toml
|
||||
[[r2_buckets]]
|
||||
binding = "MEDIA"
|
||||
bucket_name = "emdash-media"
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Public Access
|
||||
|
||||
For public media URLs, enable public access on your R2 bucket:
|
||||
|
||||
1. Go to Cloudflare Dashboard > R2 > your bucket
|
||||
2. Enable public access under Settings
|
||||
3. Add the public URL to your config:
|
||||
|
||||
```js
|
||||
storage: r2({
|
||||
binding: "MEDIA",
|
||||
publicUrl: "https://pub-xxxx.r2.dev",
|
||||
});
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Connect a custom domain to R2 for branded URLs like `https://media.example.com`.
|
||||
</Aside>
|
||||
|
||||
<Aside type="note">
|
||||
R2 bindings do not support signed upload URLs. If you need direct client uploads, use the S3
|
||||
adapter with R2 credentials instead.
|
||||
</Aside>
|
||||
|
||||
## S3-Compatible Storage
|
||||
|
||||
The S3 adapter works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
|
||||
|
||||
<Aside type="caution" title="Install the AWS SDK first">
|
||||
EmDash uses the AWS SDK at runtime for the S3 adapter but does not bundle it — core is
|
||||
deliberately SDK-agnostic so R2-only and local-only deployments stay lean. Install the SDK
|
||||
in your project before using `s3()`:
|
||||
|
||||
```sh
|
||||
pnpm add @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
```
|
||||
|
||||
If you skip this step, `astro build` will fail with `Rollup failed to resolve import
|
||||
"@aws-sdk/client-s3"`. The R2 binding adapter (`r2()`) and local adapter do not need the
|
||||
SDK and are unaffected.
|
||||
|
||||
</Aside>
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash, { s3 } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: s3({
|
||||
endpoint: process.env.S3_ENDPOINT,
|
||||
bucket: process.env.S3_BUCKET,
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
region: "auto", // Optional, defaults to "auto"
|
||||
publicUrl: process.env.S3_PUBLIC_URL, // Optional CDN URL
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Required | Description |
|
||||
| ----------------- | -------- | -------- | ------------------------------ |
|
||||
| `endpoint` | `string` | yes | S3 endpoint URL |
|
||||
| `bucket` | `string` | yes | Bucket name |
|
||||
| `accessKeyId` | `string` | no\* | Access key |
|
||||
| `secretAccessKey` | `string` | no\* | Secret key |
|
||||
| `region` | `string` | no | Region (default: `"auto"`) |
|
||||
| `publicUrl` | `string` | no | Optional CDN or public URL |
|
||||
|
||||
\* Both `accessKeyId` and `secretAccessKey` must be provided together, or both omitted.
|
||||
|
||||
### Resolving S3 config from environment variables
|
||||
|
||||
Any field omitted from `s3({...})` is read from the matching `S3_*` environment variable
|
||||
when the process starts. This lets you build a container image once and inject credentials
|
||||
at boot without a rebuild. Explicit values in `s3({...})` always take precedence over
|
||||
environment variables.
|
||||
|
||||
| Environment variable | Field | Notes |
|
||||
| ---------------------- | ----------------- | ---------------------------------- |
|
||||
| `S3_ENDPOINT` | `endpoint` | Must be a valid `http`/`https` URL |
|
||||
| `S3_BUCKET` | `bucket` | |
|
||||
| `S3_ACCESS_KEY_ID` | `accessKeyId` | |
|
||||
| `S3_SECRET_ACCESS_KEY` | `secretAccessKey` | |
|
||||
| `S3_REGION` | `region` | Defaults to `"auto"` |
|
||||
| `S3_PUBLIC_URL` | `publicUrl` | Optional CDN prefix |
|
||||
|
||||
Environment variables are read from `process.env` when the process starts. This is a
|
||||
Node-only feature.
|
||||
|
||||
```js title="astro.config.mjs — runtime environment variable example"
|
||||
import emdash, { s3 } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
// s3() with no args: all fields from S3_* environment variables
|
||||
storage: s3(),
|
||||
|
||||
// Or mix: override one field, rest from environment
|
||||
// storage: s3({ publicUrl: "https://cdn.example.com" }),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
<Aside type="note" title="Cloudflare Workers">
|
||||
This runtime resolution does not work on Cloudflare Workers. Worker secrets and variables
|
||||
are exposed through the `env` parameter of the fetch handler (or `import { env } from
|
||||
"cloudflare:workers"`), not through `process.env` — even with the `nodejs_compat` flag
|
||||
enabled. For Workers deployments:
|
||||
|
||||
- If you are using R2, use the [`r2()` adapter](#cloudflare-r2-binding) from
|
||||
`@emdash-cms/cloudflare`. This is the recommended path.
|
||||
- If you need a non-R2 S3-compatible backend on Workers, pass explicit values to
|
||||
`s3({...})` in `astro.config.mjs`. Note that `astro.config.mjs` runs at build time,
|
||||
so Worker secrets are not directly accessible there; you will need to inject values
|
||||
through your build pipeline.
|
||||
|
||||
</Aside>
|
||||
|
||||
### R2 via S3 API
|
||||
|
||||
Use S3 credentials with R2 for features like signed upload URLs:
|
||||
|
||||
```js
|
||||
storage: s3({
|
||||
endpoint: "https://<account-id>.r2.cloudflarestorage.com",
|
||||
bucket: "emdash-media",
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||
publicUrl: "https://pub-xxxx.r2.dev",
|
||||
});
|
||||
```
|
||||
|
||||
Generate R2 API credentials in the Cloudflare dashboard under R2 > Manage R2 API Tokens.
|
||||
|
||||
### MinIO
|
||||
|
||||
```js
|
||||
storage: s3({
|
||||
endpoint: "https://minio.example.com",
|
||||
bucket: "emdash-media",
|
||||
accessKeyId: process.env.MINIO_ACCESS_KEY,
|
||||
secretAccessKey: process.env.MINIO_SECRET_KEY,
|
||||
publicUrl: "https://minio.example.com/emdash-media",
|
||||
});
|
||||
```
|
||||
|
||||
## Local Filesystem
|
||||
|
||||
Use local storage for development. Files are stored in a directory on disk.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash, { local } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | ------------------------------- |
|
||||
| `directory` | `string` | Directory path for file storage |
|
||||
| `baseUrl` | `string` | Base URL for serving files |
|
||||
|
||||
The `baseUrl` should match EmDash's media file endpoint (`/_emdash/api/media/file`) unless you configure a custom static file server.
|
||||
|
||||
<Aside type="caution">
|
||||
Local storage does not support signed upload URLs. Files are uploaded through the server, which
|
||||
may be slower for large files.
|
||||
</Aside>
|
||||
|
||||
## Environment-Based Configuration
|
||||
|
||||
Switch storage backends based on environment:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import emdash, { s3, local } from "emdash/astro";
|
||||
import { r2 } from "@emdash-cms/cloudflare";
|
||||
|
||||
const storage = import.meta.env.PROD
|
||||
? r2({ binding: "MEDIA" })
|
||||
: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [emdash({ storage })],
|
||||
});
|
||||
```
|
||||
|
||||
## Signed Uploads
|
||||
|
||||
The S3 adapter supports signed upload URLs, allowing clients to upload directly to storage without passing through your server. This improves performance for large files.
|
||||
|
||||
Signed uploads are automatic when using the S3 adapter. The admin interface uses them when available.
|
||||
|
||||
Adapters that support signed uploads:
|
||||
|
||||
- **S3** (including R2 via S3 API)
|
||||
|
||||
Adapters that do not support signed uploads:
|
||||
|
||||
- **R2 binding** (use S3 adapter with R2 credentials instead)
|
||||
- **Local**
|
||||
|
||||
## Storage Interface
|
||||
|
||||
All storage adapters implement the same interface:
|
||||
|
||||
```typescript
|
||||
interface Storage {
|
||||
upload(options: {
|
||||
key: string;
|
||||
body: Buffer | Uint8Array | ReadableStream;
|
||||
contentType: string;
|
||||
}): Promise<UploadResult>;
|
||||
|
||||
download(key: string): Promise<DownloadResult>;
|
||||
delete(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
list(options?: ListOptions): Promise<ListResult>;
|
||||
getSignedUploadUrl(options: SignedUploadOptions): Promise<SignedUploadUrl>;
|
||||
getPublicUrl(key: string): string;
|
||||
}
|
||||
```
|
||||
|
||||
This consistency allows switching storage backends without changing application code.
|
||||
161
docs/src/content/docs/docs-mcp.mdx
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
title: EmDash Docs MCP
|
||||
description: Connect your AI coding assistant to the EmDash documentation so it can answer questions and find the right pattern from real docs.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
The EmDash documentation site exposes a [Model Context Protocol](https://modelcontextprotocol.io) server at `https://docs.emdashcms.com/mcp`. Connect your coding assistant to it and the assistant can search the docs as you work, instead of guessing from training data that may be out of date.
|
||||
|
||||
This is **separate from your site's MCP server** (covered in the [AI Tools guide](/guides/ai-tools)). The docs MCP only knows about EmDash's documentation -- it cannot read or modify your content. Most developers want both: the docs MCP to look things up, and the site MCP to manage content.
|
||||
|
||||
## What it does
|
||||
|
||||
The docs MCP exposes a single tool:
|
||||
|
||||
| Tool | Purpose |
|
||||
| ------------- | --------------------------------------------------------------------------------------------- |
|
||||
| `search_docs` | Search the EmDash documentation. Returns relevant chunks with source URLs and match scores. |
|
||||
|
||||
Behind the scenes it uses [Cloudflare AI Search](https://developers.cloudflare.com/ai-search/) over an index built from `docs.emdashcms.com`. The crawler keeps the index in sync with the published site, so answers reflect the docs you're reading.
|
||||
|
||||
## Connect it
|
||||
|
||||
The endpoint is:
|
||||
|
||||
```
|
||||
https://docs.emdashcms.com/mcp
|
||||
```
|
||||
|
||||
No authentication, no API key. It's public and read-only.
|
||||
|
||||
### Auto-discovery in EmDash templates
|
||||
|
||||
If you started your project from an EmDash template (`npm create emdash`), three config files are already in place and will be picked up automatically:
|
||||
|
||||
| File | Used by |
|
||||
| ------------------- | ------------ |
|
||||
| `.mcp.json` | Claude Code |
|
||||
| `.cursor/mcp.json` | Cursor |
|
||||
| `.vscode/mcp.json` | VS Code |
|
||||
|
||||
Just open the project and accept the workspace-trust prompt your tool shows on first run. Nothing else to do.
|
||||
|
||||
### Manual setup
|
||||
|
||||
If you're not using a template, or you use a different tool, add it once with the snippet for your client:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Claude Code">
|
||||
|
||||
Add the server with the Claude Code CLI:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http emdash-docs https://docs.emdashcms.com/mcp
|
||||
```
|
||||
|
||||
Or commit a `.mcp.json` at your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"emdash-docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="OpenCode">
|
||||
|
||||
Add to your `opencode.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mcp": {
|
||||
"emdash-docs": {
|
||||
"type": "remote",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Cursor">
|
||||
|
||||
Commit `.cursor/mcp.json` at your project root, or add it via **Cursor Settings -> MCP -> Add new MCP server**:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"emdash-docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="VS Code">
|
||||
|
||||
Add to `.vscode/mcp.json` in your project (or your user settings):
|
||||
|
||||
```json
|
||||
{
|
||||
"servers": {
|
||||
"emdash-docs": {
|
||||
"type": "http",
|
||||
"url": "https://docs.emdashcms.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Claude Desktop">
|
||||
|
||||
Claude Desktop only supports stdio MCP servers natively, so use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge. Add to `claude_desktop_config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"emdash-docs": {
|
||||
"command": "npx",
|
||||
"args": ["mcp-remote", "https://docs.emdashcms.com/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## When to use it
|
||||
|
||||
- You're building an EmDash site and want your AI assistant to look up the correct API, hook name, or config option from current docs rather than half-remembered training data.
|
||||
- You're writing a plugin and want to find which hooks fire in what order.
|
||||
- You're porting a WordPress theme and want examples of seed file patterns.
|
||||
- You're stuck on an error and want to search release notes and concepts.
|
||||
|
||||
## Recommend it in your AGENTS.md
|
||||
|
||||
If your project uses `AGENTS.md` (or `CLAUDE.md`, `.cursorrules`, etc.) to instruct AI tools, point them at the docs MCP so they prefer real documentation over assumptions:
|
||||
|
||||
```markdown title="AGENTS.md"
|
||||
## Documentation
|
||||
|
||||
Look up EmDash documentation via the `emdash-docs` MCP server when you need to
|
||||
verify an API, hook, config option, or pattern. Prefer the docs MCP over
|
||||
assumptions from training data -- the docs reflect the current published
|
||||
behaviour.
|
||||
```
|
||||
|
||||
The EmDash starter templates ship with this snippet pre-included.
|
||||
|
||||
<Aside title="Privacy">
|
||||
The docs MCP is hosted by the EmDash project. Queries are sent to `docs.emdashcms.com` and processed by Cloudflare AI Search. Don't include private code, secrets, or user data in queries -- the tool is designed for documentation lookup, not code analysis.
|
||||
</Aside>
|
||||
272
docs/src/content/docs/getting-started.mdx
Normal file
@@ -0,0 +1,272 @@
|
||||
---
|
||||
title: Getting Started
|
||||
description: Create your first EmDash site in under 5 minutes.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
import dashboardImg from "../../assets/screenshots/admin-dashboard.png";
|
||||
import postEditorImg from "../../assets/screenshots/admin-post-editor.png";
|
||||
|
||||
This guide walks you through creating your first EmDash site, from installation to publishing your first post.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js** v22.12.0 or higher (odd-numbered versions are not supported)
|
||||
- **npm**, **pnpm**, or **yarn**
|
||||
- A code editor (VS Code recommended)
|
||||
|
||||
## Create a New Project
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="npm">
|
||||
```bash
|
||||
npm create emdash@latest
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="pnpm">
|
||||
```bash
|
||||
pnpm create emdash@latest
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="yarn">
|
||||
```bash
|
||||
yarn create emdash
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Follow the prompts to name your project and set up your preferences.
|
||||
|
||||
## Start the Development Server
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Navigate to your project directory:
|
||||
|
||||
```bash
|
||||
cd my-emdash-site
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the dev server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open your browser to `http://localhost:4321`
|
||||
|
||||
</Steps>
|
||||
|
||||
## Complete the Setup Wizard
|
||||
|
||||
When you first visit the admin panel, EmDash's Setup Wizard guides you through initial configuration:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Navigate to `http://localhost:4321/_emdash/admin`
|
||||
|
||||
2. You'll be redirected to the Setup Wizard. Enter:
|
||||
- **Site Title** — Your site's name
|
||||
- **Tagline** — A short description
|
||||
- **Admin Email** — Your email address
|
||||
|
||||
3. Click **Create Site** to register your passkey
|
||||
|
||||
4. Your browser will prompt you to create a passkey using Touch ID, Face ID, Windows Hello, or a security key
|
||||
|
||||
</Steps>
|
||||
|
||||
Once your passkey is registered, you're logged in and redirected to the admin dashboard.
|
||||
|
||||
<img src={dashboardImg.src} alt="EmDash admin dashboard showing content overview, recent activity, and navigation sidebar" />
|
||||
|
||||
<Aside>
|
||||
EmDash uses passkey authentication instead of passwords. Passkeys are more secure and work with
|
||||
your browser's built-in password manager. See the [Authentication guide](/guides/authentication/)
|
||||
for more details.
|
||||
</Aside>
|
||||
|
||||
## Your First Post
|
||||
|
||||
<Steps>
|
||||
|
||||
1. In the admin sidebar, click **Posts** under Content.
|
||||
|
||||
2. Click **New Post**.
|
||||
|
||||
3. Enter a title and write some content using the rich text editor.
|
||||
|
||||
<img src={postEditorImg.src} alt="EmDash post editor with rich text toolbar and publish sidebar" />
|
||||
|
||||
4. Set the status to **Published** and click **Save**.
|
||||
|
||||
5. Visit your site's homepage to see your post live—no rebuild needed!
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
EmDash uses Live Content Collections. Changes appear immediately without rebuilding your site.
|
||||
</Aside>
|
||||
|
||||
## Project Structure
|
||||
|
||||
Your EmDash project follows a standard Astro structure with a few additions:
|
||||
|
||||
```
|
||||
my-emdash-site/
|
||||
├── astro.config.mjs # Astro + EmDash configuration
|
||||
├── src/
|
||||
│ ├── live.config.ts # Live Collections configuration
|
||||
│ ├── pages/
|
||||
│ │ ├── index.astro # Homepage
|
||||
│ │ └── posts/
|
||||
│ │ └── [...slug].astro # Dynamic post pages
|
||||
│ ├── layouts/
|
||||
│ │ └── Base.astro # Base layout
|
||||
│ └── components/ # Your Astro components
|
||||
├── .emdash/
|
||||
│ ├── seed.json # Template seed file
|
||||
│ └── types.ts # Generated TypeScript types
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### astro.config.mjs
|
||||
|
||||
This configures EmDash as an Astro integration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### src/live.config.ts
|
||||
|
||||
This connects EmDash to Astro's content system:
|
||||
|
||||
```ts title="src/live.config.ts"
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
```
|
||||
|
||||
<Aside>
|
||||
EmDash uses a single `_emdash` collection that internally routes to your content types (posts,
|
||||
pages, products, etc.). This keeps your `live.config.ts` minimal.
|
||||
</Aside>
|
||||
|
||||
### Environment Variables
|
||||
|
||||
For production, set an encryption key for plugin secrets:
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
```
|
||||
|
||||
Add the result to your environment as `EMDASH_ENCRYPTION_KEY`. This
|
||||
key is used to encrypt plugin secrets at rest. The current release
|
||||
validates the key but does not yet use it to encrypt anything; plugin
|
||||
secret encryption is rolling out and will start using this value
|
||||
automatically when available. Set it now so your deployment is ready.
|
||||
|
||||
```bash
|
||||
EMDASH_ENCRYPTION_KEY=emdash_enc_v1_...
|
||||
```
|
||||
|
||||
The key is never stored in the database — only encrypted ciphertext is.
|
||||
Back it up somewhere durable; losing it means losing every secret
|
||||
encrypted with it.
|
||||
|
||||
The preview HMAC secret and commenter-IP hash salt are auto-generated
|
||||
and stored in the database on first use; you don't need to set them.
|
||||
You can override either if a separate process needs to share the value
|
||||
with your main site:
|
||||
|
||||
```bash
|
||||
# Optional override — only needed if another process verifies these tokens
|
||||
EMDASH_PREVIEW_SECRET=your-preview-secret
|
||||
```
|
||||
|
||||
## Query Content in Pages
|
||||
|
||||
Use EmDash's query functions in your Astro pages. These follow Astro's live collections pattern, returning `{ entries, error }` for collections and `{ entry, error }` for single entries:
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
---
|
||||
|
||||
<Base title="Home">
|
||||
<h1>Latest Posts</h1>
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
```
|
||||
|
||||
For single entries:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<h1>{post.data.title}</h1>
|
||||
```
|
||||
|
||||
## Generate TypeScript Types
|
||||
|
||||
For full type safety, generate types from your database schema:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
|
||||
This creates `.emdash/types.ts` with interfaces for all your collections. Your editor will now autocomplete field names and catch type errors.
|
||||
|
||||
## Next Steps
|
||||
|
||||
You now have a working EmDash site! Here's where to go next:
|
||||
|
||||
- **[Core Concepts](/concepts/architecture/)** — Understand how EmDash works under the hood
|
||||
- **[Working with Content](/guides/working-with-content/)** — Learn to query and render content
|
||||
- **[Media Library](/guides/media-library/)** — Upload and manage images and files
|
||||
- **[Create a Blog](/guides/create-a-blog/)** — Build a complete blog with categories and tags
|
||||
- **[Deploy to Cloudflare](/deployment/cloudflare/)** — Take your site to production
|
||||
178
docs/src/content/docs/guides/ai-tools.mdx
Normal file
@@ -0,0 +1,178 @@
|
||||
---
|
||||
title: AI Tools
|
||||
description: Connect Claude, ChatGPT, and other AI assistants to your EmDash site.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem, LinkCard } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash has a built-in [MCP server](https://modelcontextprotocol.io) that lets AI assistants work directly with your site's content. You can ask Claude, ChatGPT, or other tools to draft posts, update pages, manage media, search your content, and more -- all through natural conversation.
|
||||
|
||||
<Aside>
|
||||
This guide covers your **site's** MCP server -- for managing the content of an EmDash site. If you want to give an AI coding assistant access to the EmDash documentation itself (so it can answer questions about EmDash as you build), see [Docs MCP for AI Tools](/docs-mcp) instead. Most developers connect both.
|
||||
</Aside>
|
||||
|
||||
## Enable the MCP Server
|
||||
|
||||
The MCP server is disabled by default. Enable it in your Astro configuration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
mcp: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Setting Up
|
||||
|
||||
Your site's MCP server URL is:
|
||||
|
||||
```
|
||||
https://example.com/_emdash/api/mcp
|
||||
```
|
||||
|
||||
Replace `example.com` with your domain. For local development, use `http://localhost:4321/_emdash/api/mcp`.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Claude">
|
||||
|
||||
Connectors added in [claude.ai](https://claude.ai) work in both the web app and Claude Desktop.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Go to [Settings > Connectors](https://claude.ai/settings/connectors)
|
||||
|
||||
2. Click **Add custom connector**
|
||||
|
||||
3. Enter your site's MCP server URL
|
||||
|
||||
4. Click **Add** -- your browser opens for you to log in and approve access
|
||||
|
||||
5. Start a new conversation, click **+** in the chat input, then **Connectors**, and toggle your site on
|
||||
|
||||
</Steps>
|
||||
|
||||
For Team and Enterprise plans, an Owner first adds the connector from [Admin Settings > Connectors](https://claude.ai/admin-settings/connectors). Members then connect individually from their own settings.
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="ChatGPT">
|
||||
|
||||
ChatGPT supports MCP servers on Pro, Business, and Enterprise plans.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Go to **Settings > Apps & Connectors > Advanced settings** and enable **Developer Mode**
|
||||
|
||||
2. Go to **Settings > Connectors > Create**
|
||||
|
||||
3. Enter a name, description, and your site's MCP server URL
|
||||
|
||||
4. Click **Create**
|
||||
|
||||
5. In a conversation, click **+** near the composer, then **More**, and select your connector
|
||||
|
||||
</Steps>
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside>
|
||||
Using a coding tool like VS Code, Cursor, or Windsurf? See the [MCP Server Reference](/reference/mcp-server) for configuration details.
|
||||
</Aside>
|
||||
|
||||
## What You Can Do
|
||||
|
||||
Once connected, you can ask the AI assistant to perform any of these operations in natural language. You don't need to know the tool names -- just describe what you want.
|
||||
|
||||
### Content
|
||||
|
||||
- **Browse content** -- "Show me the latest 10 blog posts" or "Find all draft pages"
|
||||
- **Read content** -- "Get the post called 'hello-world' and summarize it"
|
||||
- **Create content** -- "Write a new blog post about our summer sale" or "Create a draft page for the About section"
|
||||
- **Edit content** -- "Update the pricing page to mention the new plan" or "Fix the typo in the FAQ post"
|
||||
- **Publish and schedule** -- "Publish the summer sale post", "Schedule the announcement for June 1st at 9am", or "Cancel the schedule on the launch post"
|
||||
- **Compare versions** -- "Show me what changed in the homepage since it was last published"
|
||||
- **Manage drafts** -- "Discard the draft changes on the about page" or "Duplicate the newsletter template"
|
||||
- **Translations** -- "What translations exist for the welcome post?" (when i18n is enabled)
|
||||
|
||||
### Media
|
||||
|
||||
- **Browse media** -- "List all uploaded images" or "Show me PDFs in the media library"
|
||||
- **Check details** -- "Get the details for this media item"
|
||||
- **Register uploads** -- "Register the file I just uploaded to `media/2026/banner.png` as a media item"
|
||||
- **Update metadata** -- "Set the alt text on the hero image to 'Mountain sunset'"
|
||||
- **Remove files** -- "Delete the old banner image"
|
||||
|
||||
<Aside>
|
||||
The MCP transport can't carry binary uploads. To add a new image, upload the bytes via the admin UI (or your own signed-upload flow) and then ask the AI to register the metadata.
|
||||
</Aside>
|
||||
|
||||
### Search
|
||||
|
||||
- **Find content** -- "Search for posts mentioning 'accessibility'" or "Find anything about TypeScript across all collections"
|
||||
|
||||
### Taxonomies
|
||||
|
||||
- **Browse** -- "List all categories" or "Show me the tags"
|
||||
- **Create terms** -- "Add a 'tutorials' tag" or "Create a 'Frontend' subcategory under 'Engineering'"
|
||||
- **Rename terms** -- "Rename the 'frontend' category to 'Web Frontend'"
|
||||
- **Move terms** -- "Move the 'tutorials' tag under the 'guides' category" or "Detach 'react' from its parent"
|
||||
- **Delete terms** -- "Delete the unused 'archive' tag"
|
||||
|
||||
### Menus
|
||||
|
||||
- **View menus** -- "Show me the main navigation menu" or "What's in the footer menu?"
|
||||
- **Create menus** -- "Create a new 'sidebar' menu"
|
||||
- **Edit menus** -- "Rename the 'main' menu to 'Primary navigation'"
|
||||
- **Set items** -- "Replace the items in the main menu with Home, Blog, About, and Contact"
|
||||
- **Delete menus** -- "Delete the unused 'mobile' menu"
|
||||
|
||||
### Site Settings
|
||||
|
||||
- **Inspect** -- "What's the current site title?" or "Show me the social links"
|
||||
- **Update identity** -- "Set the site title to 'Acme Blog' and tagline to 'Stories from the team'"
|
||||
- **Set logo / favicon** -- "Use this image as the site logo" (after registering it with `media_create`)
|
||||
- **SEO defaults** -- "Set the default OG image to the new banner" or "Update the title separator to a vertical bar"
|
||||
- **Social handles** -- "Add our Mastodon and YouTube links to the social settings"
|
||||
|
||||
<Aside>
|
||||
Reading site settings requires the Editor role; updating them requires Admin.
|
||||
</Aside>
|
||||
|
||||
### Schema (Admin only)
|
||||
|
||||
- **Inspect** -- "What collections exist?" or "Show me the fields on the posts collection"
|
||||
- **Create collections** -- "Create a new 'testimonials' collection with name and quote fields"
|
||||
- **Modify schema** -- "Add a 'featured' boolean field to posts"
|
||||
|
||||
<Aside type="caution">
|
||||
Schema changes (creating/deleting collections and fields) modify your database structure and require Admin permissions. The AI will tell you if you don't have sufficient access.
|
||||
</Aside>
|
||||
|
||||
### Revisions
|
||||
|
||||
- **View history** -- "Show the revision history for this post"
|
||||
- **Restore** -- "Restore the post to its previous version"
|
||||
|
||||
## Permissions
|
||||
|
||||
What you can do through an AI tool depends on your EmDash role. The AI assistant operates with the same permissions you have in the admin panel:
|
||||
|
||||
| Role | What the AI can do |
|
||||
| --- | --- |
|
||||
| **Admin** | Everything, including schema changes and updating site settings |
|
||||
| **Editor** | All content, media, taxonomies, and menus. Can view schema and read settings. |
|
||||
| **Author** | Own content and media |
|
||||
| **Contributor** | Own content (no publishing) and media |
|
||||
|
||||
If you try something you don't have access to, the AI will let you know.
|
||||
|
||||
## Tips
|
||||
|
||||
- **Be specific about collections.** Say "create a blog post" rather than "create a post" if you have multiple collections.
|
||||
- **Ask for the schema first.** If you're unsure what fields a collection has, ask "What fields does the posts collection have?" before creating or editing content.
|
||||
- **Review before publishing.** Ask the AI to create content as a draft, review it in the admin panel, then ask the AI to publish it -- or publish it yourself.
|
||||
- **Use compare for review.** Before publishing, ask "Compare the live and draft versions of this post" to see exactly what will change.
|
||||
- **Rich text fields use Portable Text.** The AI can write content for rich text fields, but complex formatting is best done in the admin editor.
|
||||
|
||||
## For Developers
|
||||
|
||||
The MCP server endpoint, authentication methods, OAuth discovery, tool parameters, and error handling are documented in the [MCP Server Reference](/reference/mcp-server).
|
||||
155
docs/src/content/docs/guides/atmosphere-auth.mdx
Normal file
@@ -0,0 +1,155 @@
|
||||
---
|
||||
title: Atmosphere Login
|
||||
description: Sign in to EmDash with an Atmosphere account — the open-network identity behind Bluesky and the wider AT Protocol ecosystem.
|
||||
---
|
||||
|
||||
import { Aside, Steps } from "@astrojs/starlight/components";
|
||||
|
||||
The `@emdash-cms/auth-atproto` package adds an [**Atmosphere account**](https://atmosphereaccount.com) login option to EmDash. An Atmosphere account is a portable, user-owned identity used across [Bluesky](https://bsky.app) and other apps in the AT Protocol network. Users sign in with their handle (e.g. `alice.bsky.social`) and authenticate at their own provider — EmDash never sees a password.
|
||||
|
||||
This is a good fit when:
|
||||
|
||||
- Your contributors already have an Atmosphere account.
|
||||
- You want to gate an org-controlled domain (`*.yourcompany.com`) without managing OAuth apps or invites.
|
||||
- You're building something that's part of the wider Atmosphere and want consistent identity with the rest of your stack.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pnpm add @emdash-cms/auth-atproto
|
||||
```
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { atproto } from "@emdash-cms/auth-atproto";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
authProviders: [atproto()],
|
||||
server: {
|
||||
host: "127.0.0.1", // required for local dev — see "Local development" below
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
That's enough to put **Sign in with Atmosphere** on the login page and the setup wizard. With no allowlist configured, the first user becomes Admin and self-signup is closed for everyone after that — see [allowlists](#allowlists) to open it up.
|
||||
|
||||
No environment variables, client secret, or OAuth-app registration is required. The provider is a public OAuth client and serves its own metadata document at `/.well-known/atproto-client-metadata.json`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```js
|
||||
atproto({
|
||||
allowedDIDs: ["did:plc:abc123..."],
|
||||
allowedHandles: ["*.example.com", "alice.bsky.social"],
|
||||
defaultRole: 30, // Author
|
||||
});
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | ---------- | ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `allowedDIDs` | `string[]` | none (allow all on first) | DID allowlist. DIDs are permanent and can't be spoofed. |
|
||||
| `allowedHandles` | `string[]` | none (allow all on first) | Handle allowlist. Supports wildcards (`*.example.com`). |
|
||||
| `defaultRole` | `number` | `10` (Subscriber) | Role assigned to allowed users after the first. First user is always Admin. |
|
||||
|
||||
The full role ladder is documented in the main [authentication guide](/guides/authentication/#user-roles).
|
||||
|
||||
### Allowlists
|
||||
|
||||
If neither `allowedDIDs` nor `allowedHandles` is set, only the **first user** can sign up — anyone else attempting to log in will be rejected with `signup_not_allowed`. Existing users can always sign back in regardless of the allowlist (so removing yourself from the list won't lock you out, but won't let new people in either).
|
||||
|
||||
When at least one allowlist is configured, a user is admitted if **either** list matches:
|
||||
|
||||
- **DID match.** The user's DID is in `allowedDIDs`. DIDs are cryptographic identifiers that can't be moved or impersonated, so this is the strictest form of gating.
|
||||
- **Handle match.** The user's handle matches an entry in `allowedHandles`, exactly or via a leading-wildcard pattern (`*.example.com` matches `alice.example.com` and `bob.team.example.com`).
|
||||
|
||||
Handle allowlists are safe even though handles are mutable. Before admitting a user via a handle match, EmDash independently resolves the handle's DNS/HTTP record and verifies that it points at the same DID the provider claims. A misbehaving provider can't simply assert it owns `you@yourcompany.com`.
|
||||
|
||||
<Aside>
|
||||
Use `allowedDIDs` for individuals and `allowedHandles` with a wildcard for org-level access. The
|
||||
two are additive — list specific external DIDs alongside a wildcard for staff handles.
|
||||
</Aside>
|
||||
|
||||
### Default role
|
||||
|
||||
Allowed users land on the role you set in `defaultRole`. Only the first user — the one who completes setup — is forced to Admin. There's no group/role mapping for Atmosphere accounts; if you need finer-grained roles, change the user's role from **Settings → Users** after they've logged in once.
|
||||
|
||||
## First-user setup
|
||||
|
||||
When you start a fresh site with the Atmosphere provider configured, the setup wizard offers it as an option for creating the initial admin account.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Visit `/_emdash/admin`. The setup wizard takes you through site title, tagline, and admin email.
|
||||
|
||||
2. On the "Create admin account" step, choose **Atmosphere** and enter your handle (e.g. `alice.bsky.social`).
|
||||
|
||||
3. You'll be redirected to your account's authorization page, where you sign in however your provider supports — password, passkey, or whatever else.
|
||||
|
||||
4. After approval you're sent back to EmDash, the admin user is created with role 50 (Admin), and the email you entered in step 1 is stored against your account.
|
||||
|
||||
</Steps>
|
||||
|
||||
The same flow runs for every subsequent login — handle in, redirect to your provider, redirect back, you're signed in.
|
||||
|
||||
## Local development
|
||||
|
||||
The AT Protocol OAuth profile requires loopback redirect URIs to use an **IP literal** (`127.0.0.1` or `[::1]`), not `localhost`. EmDash transparently rewrites `://localhost` to `://127.0.0.1` when generating the redirect URI, but that means your dev session needs to start on `127.0.0.1` too — otherwise the session cookie set on `localhost` won't be visible after the redirect lands you on `127.0.0.1`.
|
||||
|
||||
Astro's dev server is Vite's dev server, and Vite binds to `localhost` by default. Tell it to listen on the loopback IP as well:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
},
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Then open `http://127.0.0.1:4321/_emdash/admin` for the whole flow.
|
||||
|
||||
<Aside type="caution">
|
||||
If you stay on `http://localhost:4321`, the round-trip to your provider succeeds but you arrive
|
||||
back on `127.0.0.1` with no session cookie and bounce back to the login page. This is a
|
||||
dev-server-binding issue, not a bug in the auth flow.
|
||||
</Aside>
|
||||
|
||||
## Production
|
||||
|
||||
There's nothing extra to configure for production. The provider serves its own client metadata at:
|
||||
|
||||
```
|
||||
https://your-site.example.com/.well-known/atproto-client-metadata.json
|
||||
```
|
||||
|
||||
Authorization servers fetch this URL during the login dance to verify the client's redirect URI. Make sure your deployment's site URL is reachable on the public internet over HTTPS — internal-only deployments behind a VPN won't be able to complete a login because the user's authorization server can't fetch the metadata document.
|
||||
|
||||
If you run EmDash behind a TLS-terminating reverse proxy, set [`siteUrl`](/reference/configuration#siteurl) so EmDash builds the right redirect URI. Without it, requests look like `http://internal-host:4321` and the metadata won't match what the auth server sees.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Account is not in the allowlist"
|
||||
|
||||
The handle or DID you signed in with isn't in `allowedDIDs` / `allowedHandles`. Check the wildcard pattern (it must start with `*.`) and remember the handle match is verified against DNS/HTTP — if the handle's DID record doesn't currently resolve to the same DID the provider returned, the match is rejected.
|
||||
|
||||
### "Self-signup is not allowed"
|
||||
|
||||
You hit the callback successfully but no allowlist is configured and you aren't the first user. Either add yourself to `allowedDIDs`/`allowedHandles`, or have an existing admin invite you so the user already exists when you log in.
|
||||
|
||||
### Login redirects to the login page with no error
|
||||
|
||||
This is almost always the loopback-cookie issue described in [Local development](#local-development). Open the admin at `http://127.0.0.1:4321` (after setting `server.host: "127.0.0.1"`) and try again.
|
||||
|
||||
### Handle resolution fails for a self-hosted handle
|
||||
|
||||
The provider verifies handles by racing DNS-over-HTTPS (Cloudflare's DoH endpoint) and an HTTP `/.well-known/atproto-did` lookup. Self-hosted handles need at least one of:
|
||||
|
||||
- A `_atproto.<handle>` DNS TXT record containing `did=<your-did>`, or
|
||||
- An `https://<handle>/.well-known/atproto-did` file containing the DID.
|
||||
|
||||
If both methods fail, the handle match is rejected even when the underlying account is valid. DIDs in `allowedDIDs` aren't affected — they're matched directly.
|
||||
454
docs/src/content/docs/guides/authentication.mdx
Normal file
@@ -0,0 +1,454 @@
|
||||
---
|
||||
title: Authentication
|
||||
description: Passkey-first authentication with pluggable providers for GitHub, Google, and the Atmosphere.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash uses passkey authentication as its primary login method. Passkeys are phishing-resistant, don't require passwords, and work across devices through your browser or password manager.
|
||||
|
||||
Beyond passkeys, you can add **pluggable login providers** — GitHub, Google, and [the Atmosphere](/guides/atmosphere-auth/) (AT Protocol) ship out of the box, and the same provider interface is open for third-party packages. Any configured provider can be used to create the first admin account, log in, or link to an existing user.
|
||||
|
||||
For Cloudflare deployments, [Cloudflare Access](#cloudflare-access) is also available as an exclusive auth method that takes over the entire login flow.
|
||||
|
||||
## How It Works
|
||||
|
||||
Passkeys use WebAuthn, a web standard that creates public-key credentials stored on your device or synced through your password manager. When you log in, your device proves possession of the credential without ever sending a password over the network.
|
||||
|
||||
Benefits of passkey authentication:
|
||||
|
||||
- **No passwords to remember or leak**
|
||||
- **Phishing-resistant** — credentials are bound to your site's domain
|
||||
- **Cross-device sync** — works with iCloud Keychain, Google Password Manager, 1Password, etc.
|
||||
- **Fast login** — one tap with biometrics or PIN
|
||||
|
||||
## First User Setup
|
||||
|
||||
The first time you access the admin panel, the Setup Wizard guides you through creating your admin account.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Navigate to `http://localhost:4321/_emdash/admin`
|
||||
|
||||
2. You'll be redirected to the Setup Wizard. Enter:
|
||||
- **Site Title** — Your site's name
|
||||
- **Tagline** — A short description
|
||||
- **Admin Email** — Your email address
|
||||
|
||||
3. Click **Create Site** to register your passkey
|
||||
|
||||
4. Your browser will prompt you to create a passkey:
|
||||
- On macOS: Touch ID, device password, or security key
|
||||
- On Windows: Windows Hello or security key
|
||||
- On mobile: Face ID, fingerprint, or PIN
|
||||
|
||||
5. Once your passkey is registered, you're logged in and redirected to the admin dashboard.
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside>
|
||||
Your email is stored but not verified during initial setup. You can configure email later to
|
||||
enable features like invites and magic link login.
|
||||
</Aside>
|
||||
|
||||
## Logging In
|
||||
|
||||
After setup, returning to the admin panel triggers passkey authentication:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Visit `/_emdash/admin`
|
||||
|
||||
2. If not logged in, you'll see the login page
|
||||
|
||||
3. Click **Sign in** to authenticate
|
||||
|
||||
4. Your browser prompts for your passkey (biometrics, PIN, or security key)
|
||||
|
||||
5. After verification, you're redirected to the admin dashboard
|
||||
|
||||
</Steps>
|
||||
|
||||
## Magic Link Fallback
|
||||
|
||||
If you can't use your passkey (e.g., lost device), magic links provide an alternative. This requires email to be configured.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. On the login page, click **Sign in with email**
|
||||
|
||||
2. Enter your email address
|
||||
|
||||
3. Check your inbox for a login link
|
||||
|
||||
4. Click the link to authenticate (valid for 15 minutes)
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution">
|
||||
Magic links are single-use and expire after 15 minutes. Request a new link if yours has expired.
|
||||
</Aside>
|
||||
|
||||
## Login Providers
|
||||
|
||||
In addition to passkeys, EmDash supports pluggable login providers that appear on the login page and in the setup wizard. GitHub, Google, and Atmosphere providers ship in the box; third-party packages can register their own using the same interface.
|
||||
|
||||
Providers are additive — passkeys keep working when providers are enabled, and users can link a provider to an existing passkey-only account. The first user can also be created through any configured provider, so a fresh install can skip passkeys entirely if you prefer.
|
||||
|
||||
### Configuring providers
|
||||
|
||||
Pass providers to the `authProviders` array on the EmDash integration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { github } from "emdash/auth/providers/github";
|
||||
import { google } from "emdash/auth/providers/google";
|
||||
import { atproto } from "@emdash-cms/auth-atproto";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
authProviders: [github(), google(), atproto()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Order matters for the login page: providers render in the order you list them, with compact button-only providers first and providers that need a custom form (like Atmosphere, which asks for a handle) shown after.
|
||||
|
||||
### GitHub
|
||||
|
||||
```js
|
||||
import { github } from "emdash/auth/providers/github";
|
||||
|
||||
emdash({ authProviders: [github()] });
|
||||
```
|
||||
|
||||
Set credentials via environment variables. EmDash checks the prefixed names first and falls back to the unprefixed ones:
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------------------------- | ---------------------- |
|
||||
| `EMDASH_OAUTH_GITHUB_CLIENT_ID` / `GITHUB_CLIENT_ID` | OAuth app client ID |
|
||||
| `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` / `GITHUB_CLIENT_SECRET` | OAuth app secret |
|
||||
|
||||
Configure your GitHub OAuth app's callback URL as `https://your-site.example.com/_emdash/api/auth/oauth/github/callback`.
|
||||
|
||||
### Google
|
||||
|
||||
```js
|
||||
import { google } from "emdash/auth/providers/google";
|
||||
|
||||
emdash({ authProviders: [google()] });
|
||||
```
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------------------------- | ---------------------- |
|
||||
| `EMDASH_OAUTH_GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_ID` | OAuth app client ID |
|
||||
| `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET` / `GOOGLE_CLIENT_SECRET` | OAuth app secret |
|
||||
|
||||
Configure your Google OAuth client's redirect URI as `https://your-site.example.com/_emdash/api/auth/oauth/google/callback`.
|
||||
|
||||
### Atmosphere (AT Protocol)
|
||||
|
||||
For sites where contributors already have an account on the AT Protocol network — the same identity that powers Bluesky and a growing collection of other apps — install the Atmosphere provider:
|
||||
|
||||
```bash
|
||||
pnpm add @emdash-cms/auth-atproto
|
||||
```
|
||||
|
||||
```js
|
||||
import { atproto } from "@emdash-cms/auth-atproto";
|
||||
|
||||
emdash({
|
||||
authProviders: [
|
||||
atproto({
|
||||
allowedHandles: ["*.example.com"],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
No client secret or environment variable is needed. See the [Atmosphere login guide](/guides/atmosphere-auth/) for handle/DID allowlists, role mapping, and the local-development setup that the AT Protocol OAuth profile requires.
|
||||
|
||||
### Building your own provider
|
||||
|
||||
A provider is just an `AuthProviderDescriptor` — an `id`, a human label, and any combination of admin-side React components, route handlers, public route prefixes, and storage collections. The shape is exported from `emdash`:
|
||||
|
||||
```ts
|
||||
import type { AuthProviderDescriptor } from "emdash";
|
||||
|
||||
export function myProvider(): AuthProviderDescriptor {
|
||||
return {
|
||||
id: "my-provider",
|
||||
label: "My Provider",
|
||||
adminEntry: "my-provider/admin", // exports LoginButton / LoginForm / SetupStep
|
||||
routes: [
|
||||
{ pattern: "/_emdash/api/auth/my-provider/login", entrypoint: "my-provider/routes/login.ts" },
|
||||
{ pattern: "/_emdash/api/auth/my-provider/callback", entrypoint: "my-provider/routes/callback.ts" },
|
||||
],
|
||||
publicRoutes: ["/_emdash/api/auth/my-provider/"],
|
||||
storage: {
|
||||
sessions: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The Atmosphere package (`@emdash-cms/auth-atproto`) is the most complete real-world reference for a provider that needs a custom login form, OAuth route handlers, and persistent storage.
|
||||
|
||||
## User Roles
|
||||
|
||||
EmDash uses role-based access control with five levels:
|
||||
|
||||
| Role | Level | Description |
|
||||
| ----------- | ----- | ------------------------------------------ |
|
||||
| Subscriber | 10 | Read published content (no draft access) |
|
||||
| Contributor | 20 | Create content (needs approval to publish) |
|
||||
| Author | 30 | Create/edit/publish own content |
|
||||
| Editor | 40 | Manage all content |
|
||||
| Admin | 50 | Full access including settings |
|
||||
|
||||
Each role inherits permissions from all lower levels. The first user is always created as Admin.
|
||||
|
||||
### Subscribers and draft content
|
||||
|
||||
Subscribers hold the `content:read` permission so member-only published content can be served to authenticated readers. They cannot see drafts, scheduled items, trashed items, revisions, or preview URLs — those are gated on `content:read_drafts`, granted to Contributor and above. The list and get endpoints transparently filter to `status=published` for Subscribers; editor-only views (`/compare`, `/revisions`, `/trash`, `/preview-url`) reject Subscriber requests outright.
|
||||
|
||||
## Inviting Users
|
||||
|
||||
Admins can invite new users via the admin panel:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Go to **Settings** > **Users**
|
||||
|
||||
2. Click **Invite User**
|
||||
|
||||
3. Enter the user's email and select a role
|
||||
|
||||
4. Click **Send Invite**
|
||||
|
||||
5. The user receives an email with an invite link
|
||||
|
||||
6. They click the link and register their passkey
|
||||
|
||||
</Steps>
|
||||
|
||||
Invites are valid for 7 days. Admins can resend or revoke invites from the Users page.
|
||||
|
||||
## Managing Passkeys
|
||||
|
||||
Users can manage their passkeys from the account settings:
|
||||
|
||||
- **Add passkey** — Register additional passkeys for backup or other devices
|
||||
- **Remove passkey** — Delete passkeys you no longer use
|
||||
- **Rename passkey** — Give passkeys descriptive names
|
||||
|
||||
Each user can have up to 10 passkeys registered.
|
||||
|
||||
<Aside type="tip">
|
||||
Register passkeys on multiple devices (e.g., laptop and phone) to ensure you always have a way to
|
||||
log in.
|
||||
</Aside>
|
||||
|
||||
## Self-Signup
|
||||
|
||||
For team sites, you can enable self-signup for specific email domains:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
auth: {
|
||||
selfSignup: {
|
||||
domains: ["example.com"],
|
||||
defaultRole: "contributor",
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Users with matching email domains can sign up without an invite. They'll receive a verification email and register a passkey to complete signup.
|
||||
|
||||
## Session Configuration
|
||||
|
||||
Sessions use secure HttpOnly cookies with sensible defaults:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
auth: {
|
||||
session: {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days (default)
|
||||
sliding: true, // Reset expiry on activity
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- **Passkeys are stored as public keys** — the private key never leaves your device
|
||||
- **Challenge verification** prevents replay attacks
|
||||
- **Rate limiting** protects against brute force (5 attempts/minute/IP)
|
||||
- **Sessions are HttpOnly, Secure, SameSite=Lax** for cookie security
|
||||
- **Magic link tokens are SHA-256 hashed** — raw tokens are never stored
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "No passkeys registered"
|
||||
|
||||
If you see this error on login, your passkey may have been deleted from your password manager. Ask an admin to send you a magic link or new invite.
|
||||
|
||||
### "Passkey authentication failed"
|
||||
|
||||
This usually means the passkey was created for a different domain. Passkeys are domain-bound — a passkey for `localhost:4321` won't work on `example.com`. Register a new passkey for each domain.
|
||||
|
||||
### "Session expired"
|
||||
|
||||
Sessions last 30 days by default with sliding expiration. If you're logged out unexpectedly, clear your cookies and log in again.
|
||||
|
||||
### Lost all passkeys
|
||||
|
||||
If you've lost access to all your registered passkeys:
|
||||
|
||||
1. Ask another admin to send you a magic link (requires email configuration)
|
||||
2. Use the magic link to log in
|
||||
3. Register a new passkey in account settings
|
||||
|
||||
If you're the only admin and email isn't configured, you'll need to reset your site's authentication through the database.
|
||||
|
||||
## Cloudflare Access
|
||||
|
||||
When deploying to Cloudflare, you can use [Cloudflare Access](https://developers.cloudflare.com/cloudflare-one/applications/) as your authentication provider instead of passkeys. Access handles authentication at the edge using your existing identity provider.
|
||||
|
||||
<Aside>
|
||||
When Cloudflare Access is configured, it becomes the **exclusive** authentication method.
|
||||
Passkeys, OAuth, magic links, and self-signup are all disabled.
|
||||
</Aside>
|
||||
|
||||
### Why Use Cloudflare Access?
|
||||
|
||||
- **Single Sign-On** — Users authenticate with your company's IdP
|
||||
- **Centralized access control** — Manage who can access the admin in the Cloudflare dashboard
|
||||
- **No passkey management** — No need to register or manage passkeys
|
||||
- **Group-based roles** — Map IdP groups to EmDash roles automatically
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a [Cloudflare Access application](https://developers.cloudflare.com/cloudflare-one/applications/configure-apps/self-hosted-apps/) for your EmDash site
|
||||
2. Note the **Application Audience (AUD) Tag** from the application settings
|
||||
3. Configure EmDash to use Access:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import emdash from "emdash/astro";
|
||||
import { d1, access } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
output: "server",
|
||||
adapter: cloudflare(),
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "abc123def456...", // From Access app settings
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | --------- | -------- | ------------------------------------------------------------- |
|
||||
| `teamDomain` | `string` | required | Your Access team domain (e.g., `myteam.cloudflareaccess.com`) |
|
||||
| `audience` | `string` | required | Application Audience (AUD) tag from Access settings |
|
||||
| `autoProvision` | `boolean` | `true` | Create EmDash users on first Access login |
|
||||
| `defaultRole` | `number` | `30` | Role for users not matching any group (30 = Author) |
|
||||
| `syncRoles` | `boolean` | `false` | Update role on each login based on IdP groups |
|
||||
| `roleMapping` | `object` | — | Map IdP group names to role levels |
|
||||
| `audienceEnvVar`| `string` | `"CF_ACCESS_AUDIENCE"` | Environment variable name for the audience tag (alternative to hardcoding) |
|
||||
|
||||
### Role Mapping
|
||||
|
||||
Map your IdP groups to EmDash roles:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
auth: access({
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "abc123...",
|
||||
roleMapping: {
|
||||
Admins: 50, // Admin
|
||||
"Content Editors": 40, // Editor
|
||||
Writers: 30, // Author
|
||||
},
|
||||
defaultRole: 20, // Contributor for users not in any group
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
The first matching group wins if a user belongs to multiple groups. The **first user** to access the site always becomes Admin, regardless of groups.
|
||||
|
||||
### Role Sync Behavior
|
||||
|
||||
By default (`syncRoles: false`), a user's role is set when they first log in and doesn't change afterward. This allows admins to manually adjust roles in EmDash.
|
||||
|
||||
Set `syncRoles: true` if you want IdP groups to be authoritative — the user's role will update on every login based on their current groups.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. User visits `/_emdash/admin`
|
||||
2. Cloudflare Access intercepts and redirects to your IdP
|
||||
3. User authenticates (SSO, MFA, etc.)
|
||||
4. Access sets a signed JWT in the request
|
||||
5. EmDash validates the JWT and creates/authenticates the user
|
||||
|
||||
<Aside type="tip">
|
||||
You can still disable users locally in EmDash. A disabled user will be rejected even if Access
|
||||
allows them through.
|
||||
</Aside>
|
||||
|
||||
### Disabled Features
|
||||
|
||||
When Access is enabled, these features are unavailable:
|
||||
|
||||
- Login page (`/_emdash/admin/login`)
|
||||
- Passkey registration and management
|
||||
- OAuth login
|
||||
- Magic link login
|
||||
- Self-signup
|
||||
- User invites
|
||||
|
||||
User management is done entirely through your Cloudflare Access policies.
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
#### "No Access JWT present"
|
||||
|
||||
The request reached EmDash without an Access JWT. This means:
|
||||
|
||||
- Access isn't configured to protect your application
|
||||
- The Access policy isn't matching the admin routes
|
||||
|
||||
Verify your Access application covers `/_emdash/admin/*`.
|
||||
|
||||
#### "JWT audience mismatch"
|
||||
|
||||
The `audience` in your config doesn't match the JWT. Double-check the Application Audience Tag in your Access application settings.
|
||||
|
||||
#### "User not authorized"
|
||||
|
||||
The user authenticated via Access but `autoProvision` is `false` and they don't exist in EmDash. Either:
|
||||
|
||||
- Set `autoProvision: true`, or
|
||||
- Create the user manually before they log in
|
||||
320
docs/src/content/docs/guides/create-a-blog.mdx
Normal file
@@ -0,0 +1,320 @@
|
||||
---
|
||||
title: Create a Blog
|
||||
description: Build a complete blog with posts, categories, and tags using EmDash.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
|
||||
import postsListImg from "../../../assets/screenshots/admin-posts-list.png";
|
||||
import postEditorImg from "../../../assets/screenshots/admin-post-editor.png";
|
||||
|
||||
This guide walks you through creating a blog with EmDash, from defining your content type to displaying posts with categories and tags.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An EmDash site set up and running (see [Getting Started](/getting-started/))
|
||||
- Basic familiarity with Astro components
|
||||
|
||||
## Define the Posts Collection
|
||||
|
||||
EmDash creates a default "posts" collection during setup. If you need to customize it, use the admin dashboard or API.
|
||||
|
||||
The default posts collection includes:
|
||||
|
||||
- `title` - Post title
|
||||
- `slug` - URL-friendly identifier
|
||||
- `content` - Rich text body
|
||||
- `excerpt` - Short description
|
||||
- `featured_image` - Header image (optional)
|
||||
- `status` - draft, published, or scheduled
|
||||
- `publishedAt` - Publication date (system field)
|
||||
|
||||
## Create Your First Post
|
||||
|
||||
1. Open the admin dashboard at `/_emdash/admin`
|
||||
|
||||
2. Click **Posts** in the sidebar
|
||||
|
||||
<img src={postsListImg.src} alt="EmDash posts list showing titles, status, and dates" />
|
||||
|
||||
3. Click **New Post**
|
||||
|
||||
<img src={postEditorImg.src} alt="EmDash post editor with title, content, and publish options" />
|
||||
|
||||
4. Enter a title and write your content using the rich text editor
|
||||
|
||||
5. Add categories and tags in the sidebar panel
|
||||
|
||||
6. Set the status to **Published**
|
||||
|
||||
7. Click **Save**
|
||||
|
||||
Your post is now live. No rebuild required.
|
||||
|
||||
## Display Posts on Your Site
|
||||
|
||||
### List All Posts
|
||||
|
||||
Create a page that displays all published posts:
|
||||
|
||||
```astro title="src/pages/blog/index.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Sort by publication date, newest first
|
||||
const sortedPosts = posts.sort(
|
||||
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0)
|
||||
);
|
||||
---
|
||||
|
||||
<Base title="Blog">
|
||||
<h1>Blog</h1>
|
||||
<ul>
|
||||
{sortedPosts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.data.slug}`}>
|
||||
<h2>{post.data.title}</h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
<time datetime={post.data.publishedAt?.toISOString()}>
|
||||
{post.data.publishedAt?.toLocaleDateString()}
|
||||
</time>
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Display a Single Post
|
||||
|
||||
Create a dynamic route for individual posts:
|
||||
|
||||
```astro title="src/pages/blog/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.data.slug },
|
||||
}));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={post.data.title}>
|
||||
<article>
|
||||
{post.data.featured_image && (
|
||||
<img src={post.data.featured_image} alt="" />
|
||||
)}
|
||||
<h1>{post.data.title}</h1>
|
||||
<time datetime={post.data.publishedAt?.toISOString()}>
|
||||
{post.data.publishedAt?.toLocaleDateString()}
|
||||
</time>
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
For server-rendered sites, remove `getStaticPaths` and the page will render on demand. EmDash's
|
||||
live content collections work in both modes.
|
||||
</Aside>
|
||||
|
||||
## Add Categories and Tags
|
||||
|
||||
EmDash includes built-in category and tag taxonomies. See [Taxonomies](/guides/taxonomies/) for details on creating and managing terms.
|
||||
|
||||
### Filter Posts by Category
|
||||
|
||||
```astro title="src/pages/category/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getTerm, getTaxonomyTerms } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
|
||||
// Flatten hierarchical categories
|
||||
const flatten = (terms) => terms.flatMap((t) => [t, ...flatten(t.children)]);
|
||||
|
||||
return flatten(categories).map((cat) => ({
|
||||
params: { slug: cat.slug },
|
||||
props: { category: cat },
|
||||
}));
|
||||
}
|
||||
|
||||
const { category } = Astro.props;
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: category.slug },
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={category.label}>
|
||||
<h1>{category.label}</h1>
|
||||
{category.description && <p>{category.description}</p>}
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Display Post Categories
|
||||
|
||||
Show categories on individual posts:
|
||||
|
||||
```astro title="src/components/PostMeta.astro"
|
||||
---
|
||||
import { getEntryTerms } from "emdash";
|
||||
|
||||
interface Props {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
const { postId } = Astro.props;
|
||||
const categories = await getEntryTerms("posts", postId, "category");
|
||||
const tags = await getEntryTerms("posts", postId, "tag");
|
||||
---
|
||||
|
||||
<div class="post-meta">
|
||||
{categories.length > 0 && (
|
||||
<div class="categories">
|
||||
<span>Categories:</span>
|
||||
{categories.map((cat) => (
|
||||
<a href={`/category/${cat.slug}`}>{cat.label}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div class="tags">
|
||||
<span>Tags:</span>
|
||||
{tags.map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Add Pagination
|
||||
|
||||
For blogs with many posts, add pagination:
|
||||
|
||||
```astro title="src/pages/blog/page/[page].astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../../../layouts/Base.astro";
|
||||
|
||||
const POSTS_PER_PAGE = 10;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { entries: allPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
|
||||
|
||||
return Array.from({ length: totalPages }, (_, i) => ({
|
||||
params: { page: String(i + 1) },
|
||||
props: { currentPage: i + 1, totalPages },
|
||||
}));
|
||||
}
|
||||
|
||||
const { currentPage, totalPages } = Astro.props;
|
||||
|
||||
const { entries: allPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
const sortedPosts = allPosts.sort(
|
||||
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0)
|
||||
);
|
||||
|
||||
const start = (currentPage - 1) * POSTS_PER_PAGE;
|
||||
const posts = sortedPosts.slice(start, start + POSTS_PER_PAGE);
|
||||
---
|
||||
|
||||
<Base title={`Blog - Page ${currentPage}`}>
|
||||
<h1>Blog</h1>
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<nav>
|
||||
{currentPage > 1 && (
|
||||
<a href={`/blog/page/${currentPage - 1}`}>Previous</a>
|
||||
)}
|
||||
<span>Page {currentPage} of {totalPages}</span>
|
||||
{currentPage < totalPages && (
|
||||
<a href={`/blog/page/${currentPage + 1}`}>Next</a>
|
||||
)}
|
||||
</nav>
|
||||
</Base>
|
||||
```
|
||||
|
||||
## Add an RSS Feed
|
||||
|
||||
Create an RSS feed for your blog:
|
||||
|
||||
```ts title="src/pages/rss.xml.ts"
|
||||
import rss from "@astrojs/rss";
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
export async function GET(context) {
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
return rss({
|
||||
title: "My Blog",
|
||||
description: "A blog built with EmDash",
|
||||
site: context.site,
|
||||
items: posts.map((post) => ({
|
||||
title: post.data.title,
|
||||
pubDate: post.data.publishedAt,
|
||||
description: post.data.excerpt,
|
||||
link: `/blog/${post.data.slug}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Install the RSS package if you haven't already:
|
||||
|
||||
```bash
|
||||
npm install @astrojs/rss
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Working with Content](/guides/working-with-content/) - Learn CRUD operations in the admin
|
||||
- [Media Library](/guides/media-library/) - Add images to your posts
|
||||
- [Taxonomies](/guides/taxonomies/) - Create custom classification systems
|
||||
329
docs/src/content/docs/guides/internationalization.mdx
Normal file
@@ -0,0 +1,329 @@
|
||||
---
|
||||
title: Internationalization (i18n)
|
||||
description: Translate content into multiple languages with per-locale publishing, slugs, and automatic fallback.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash integrates with [Astro's built-in i18n routing](https://docs.astro.build/en/guides/internationalization/) to provide multilingual content management. Astro handles URL routing and locale detection; EmDash handles translated content storage and retrieval.
|
||||
|
||||
Each translation is a full, independent content entry with its own slug, status, and revision history. The French version of a post can be in draft while the English version is published.
|
||||
|
||||
## Configuration
|
||||
|
||||
Enable i18n by adding an `i18n` block to your Astro config. EmDash reads this configuration automatically — there is no separate locale setup in EmDash.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en", "fr", "es"],
|
||||
fallback: { fr: "en", es: "en" },
|
||||
},
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
When `i18n` is not present in the Astro config, all i18n features are disabled and EmDash behaves as a single-language CMS.
|
||||
|
||||
## How Translations Work
|
||||
|
||||
EmDash uses a **row-per-locale** model. Each translation is its own row in the database with its own ID, slug, and status, linked to other translations via a shared `translation_group` identifier.
|
||||
|
||||
```
|
||||
ec_posts:
|
||||
id | slug | locale | translation_group | status
|
||||
---------|-------------|--------|-------------------|----------
|
||||
01ABC... | my-post | en | 01ABC... | published
|
||||
01DEF... | mon-article | fr | 01ABC... | draft
|
||||
01GHI... | mi-entrada | es | 01ABC... | published
|
||||
```
|
||||
|
||||
This design means:
|
||||
|
||||
- **Per-locale slugs** — `/blog/my-post` and `/fr/blog/mon-article` work naturally
|
||||
- **Per-locale publishing** — publish the English version while keeping French in draft
|
||||
- **Per-locale revisions** — each translation has its own revision history
|
||||
- **No cross-locale query complexity** — list queries return entries for one locale only
|
||||
|
||||
## Querying Translated Content
|
||||
|
||||
### Single entry
|
||||
|
||||
Pass `locale` to `getEmDashEntry` to retrieve a specific translation. When omitted, it defaults to the request's current locale (set by Astro's i18n middleware).
|
||||
|
||||
```astro title="src/pages/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post, error } = await getEmDashEntry("posts", slug, {
|
||||
locale: Astro.currentLocale,
|
||||
});
|
||||
|
||||
if (!post) return Astro.redirect("/404");
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Fallback chain
|
||||
|
||||
When no content exists for the requested locale, EmDash follows the fallback chain defined in your Astro config. Given `fallback: { fr: "en" }`:
|
||||
|
||||
1. Try the requested locale (`fr`)
|
||||
2. Try the fallback locale (`en`)
|
||||
3. Try the default locale
|
||||
|
||||
Fallback only applies to single-entry queries. List queries return entries for the requested locale only — no cross-locale mixing.
|
||||
|
||||
<Aside>
|
||||
When a fallback is used, the response metadata includes `fallbackLocale` so your template can display a "this content is not yet translated" notice.
|
||||
</Aside>
|
||||
|
||||
### Collection listing
|
||||
|
||||
Filter a collection by locale:
|
||||
|
||||
```astro title="src/pages/posts.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
locale: Astro.currentLocale,
|
||||
status: "published",
|
||||
});
|
||||
---
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li><a href={`/${post.data.slug}`}>{post.data.title}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Language Switcher
|
||||
|
||||
Use `getTranslations` to build a language switcher that links to existing translations of the current entry:
|
||||
|
||||
```astro title="src/components/LanguageSwitcher.astro"
|
||||
---
|
||||
import { getTranslations } from "emdash";
|
||||
import { getRelativeLocaleUrl } from "astro:i18n";
|
||||
|
||||
interface Props {
|
||||
collection: string;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
const { collection, entryId } = Astro.props;
|
||||
const { translations } = await getTranslations(collection, entryId);
|
||||
---
|
||||
|
||||
<nav aria-label="Language">
|
||||
<ul>
|
||||
{translations.map((t) => (
|
||||
<li>
|
||||
<a
|
||||
href={getRelativeLocaleUrl(t.locale, `/blog/${t.slug}`)}
|
||||
aria-current={t.locale === Astro.currentLocale ? "page" : undefined}
|
||||
>
|
||||
{t.locale.toUpperCase()}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
|
||||
The `getTranslations` function returns all locale variants in the same translation group:
|
||||
|
||||
```ts
|
||||
const { translationGroup, translations } = await getTranslations("posts", post.entry.id);
|
||||
// translations: [
|
||||
// { locale: "en", id: "01ABC...", slug: "my-post", status: "published" },
|
||||
// { locale: "fr", id: "01DEF...", slug: "mon-article", status: "draft" },
|
||||
// ]
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Use `getRelativeLocaleUrl` from `astro:i18n` to build locale-prefixed URLs. This respects your Astro routing strategy (`prefix-other-locales` or `prefix-always`).
|
||||
</Aside>
|
||||
|
||||
## Managing Translations in the Admin
|
||||
|
||||
### Content list
|
||||
|
||||
When i18n is enabled, the content list shows:
|
||||
|
||||
- A **locale column** displaying each entry's locale
|
||||
- A **locale filter** in the toolbar to switch between locales
|
||||
|
||||
### Creating translations
|
||||
|
||||
Open any content entry in the editor. The sidebar displays a **Translations** panel listing all configured locales. For each locale:
|
||||
|
||||
- **"Translate"** appears for locales without a translation — click to create one
|
||||
- **"Edit"** appears for locales with an existing translation — click to navigate to it
|
||||
- The current locale is marked with a checkmark
|
||||
|
||||
When creating a translation, the new entry is pre-filled with data from the source locale and assigned a default slug of `{source-slug}-{locale}`. Adjust the slug and content as needed, then save.
|
||||
|
||||
### Per-locale publishing
|
||||
|
||||
Each translation has its own status. Publish, unpublish, or schedule translations independently. The French version can be in draft while the English version is live.
|
||||
|
||||
## Content API
|
||||
|
||||
### Locale parameter
|
||||
|
||||
All content API routes accept an optional `locale` query parameter:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/content/posts?locale=fr
|
||||
GET /_emdash/api/content/posts/my-post?locale=fr
|
||||
```
|
||||
|
||||
When omitted, defaults to the configured default locale.
|
||||
|
||||
### Creating translations via API
|
||||
|
||||
Create a translation by passing `locale` and `translationOf` to the content create endpoint:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/content/posts
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"locale": "fr",
|
||||
"translationOf": "01ABC...",
|
||||
"data": {
|
||||
"title": "Mon Article",
|
||||
"slug": "mon-article"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The new entry shares the source entry's `translation_group` and starts as a draft.
|
||||
|
||||
### Listing translations
|
||||
|
||||
Retrieve all translations for a given entry:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/content/posts/01ABC.../translations
|
||||
```
|
||||
|
||||
Returns the translation group ID and an array of locale variants with their IDs, slugs, and statuses.
|
||||
|
||||
## CLI
|
||||
|
||||
The CLI supports `--locale` flags on content commands:
|
||||
|
||||
```bash
|
||||
# List French posts
|
||||
emdash content list posts --locale fr
|
||||
|
||||
# Get a specific entry in French
|
||||
emdash content get posts my-post --locale fr
|
||||
|
||||
# Create a French translation of an existing entry
|
||||
emdash content create posts --locale fr --translation-of 01ABC...
|
||||
```
|
||||
|
||||
## Seeding Multilingual Content
|
||||
|
||||
Seed files express translations using `locale` and `translationOf`:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"content": {
|
||||
"posts": [
|
||||
{
|
||||
"id": "welcome",
|
||||
"slug": "welcome",
|
||||
"locale": "en",
|
||||
"status": "published",
|
||||
"data": { "title": "Welcome" }
|
||||
},
|
||||
{
|
||||
"id": "welcome-fr",
|
||||
"slug": "bienvenue",
|
||||
"locale": "fr",
|
||||
"translationOf": "welcome",
|
||||
"status": "draft",
|
||||
"data": { "title": "Bienvenue" }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The source locale entry must appear before its translations in the seed file so that `translationOf` references resolve correctly.
|
||||
|
||||
## Field Translatability
|
||||
|
||||
Each field has a `translatable` setting (default: `true`). When creating a translation:
|
||||
|
||||
- **Translatable fields** are pre-filled from the source locale for editing
|
||||
- **Non-translatable fields** are copied and kept in sync across all translations in the group
|
||||
|
||||
System fields like `status`, `published_at`, and `author_id` are always per-locale and never synced.
|
||||
|
||||
<Aside>
|
||||
Non-translatable fields are useful for values that should stay consistent across locales, such as a product SKU or a sort order number.
|
||||
</Aside>
|
||||
|
||||
## URL Strategy
|
||||
|
||||
EmDash does not manage locale URLs — Astro handles routing. Common patterns:
|
||||
|
||||
```
|
||||
# prefix-other-locales (Astro default)
|
||||
/blog/my-post → en (default locale, no prefix)
|
||||
/fr/blog/mon-article → fr
|
||||
|
||||
# prefix-always
|
||||
/en/blog/my-post → en
|
||||
/fr/blog/mon-article → fr
|
||||
```
|
||||
|
||||
Use `getRelativeLocaleUrl` from `astro:i18n` to build correct URLs regardless of routing mode.
|
||||
|
||||
## Importing Multilingual Content
|
||||
|
||||
### WordPress with WPML or Polylang
|
||||
|
||||
The WordPress plugin import source detects WPML and Polylang automatically. When detected, imported content includes locale and translation group metadata, preserving the multilingual structure.
|
||||
|
||||
### WXR files
|
||||
|
||||
WXR exports do not include WPML/Polylang metadata. Import as a single locale and create translations manually, or use the `--locale` flag to assign a locale to all imported items:
|
||||
|
||||
```bash
|
||||
# Import a French WXR export
|
||||
emdash import wordpress export-fr.xml --execute --locale fr
|
||||
|
||||
# Match against existing English content by slug
|
||||
emdash import wordpress export-fr.xml --execute --locale fr --translation-of-locale en
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Querying Content](/guides/querying-content/) — Full query API reference
|
||||
- [Working with Content](/guides/working-with-content/) — Admin content management
|
||||
- [Astro i18n routing](https://docs.astro.build/en/guides/internationalization/) — Astro's routing configuration
|
||||
487
docs/src/content/docs/guides/media-library.mdx
Normal file
@@ -0,0 +1,487 @@
|
||||
---
|
||||
title: Media Library
|
||||
description: Upload and manage images and files in EmDash.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
import mediaLibraryImg from "../../../assets/screenshots/admin-media-library.png";
|
||||
|
||||
EmDash includes a media library for managing images, documents, and other files. This guide covers uploading, organizing, and using media in your content.
|
||||
|
||||
## Accessing the Media Library
|
||||
|
||||
Open the media library from the admin sidebar by clicking **Media**. The library displays all uploaded files with previews, filenames, and upload dates.
|
||||
|
||||
<img src={mediaLibraryImg.src} alt="EmDash media library showing image grid with upload button" />
|
||||
|
||||
## Uploading Files
|
||||
|
||||
### From the Media Library
|
||||
|
||||
1. Click **Media** in the admin sidebar
|
||||
|
||||
2. Click **Upload** or drag files onto the upload area
|
||||
|
||||
3. Select one or more files from your computer
|
||||
|
||||
4. Wait for uploads to complete
|
||||
|
||||
### From the Content Editor
|
||||
|
||||
1. In the rich text editor, click the image button
|
||||
|
||||
2. Click **Upload** in the media picker
|
||||
|
||||
3. Select a file from your computer
|
||||
|
||||
4. Add alt text and click **Insert**
|
||||
|
||||
## Supported File Types
|
||||
|
||||
EmDash supports common web file types:
|
||||
|
||||
| Category | Extensions |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| Images | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif`, `.svg` |
|
||||
| Documents | `.pdf`, `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx` |
|
||||
| Video | `.mp4`, `.webm`, `.mov` |
|
||||
| Audio | `.mp3`, `.wav`, `.ogg` |
|
||||
|
||||
<Aside type="caution">
|
||||
Maximum file size depends on your storage configuration. The default limit is 10MB per file.
|
||||
</Aside>
|
||||
|
||||
## Storage Backends
|
||||
|
||||
EmDash supports multiple storage backends. Configure storage in your Astro config:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Local Storage">
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Files are stored in the `./uploads` directory. Suitable for development and single-server deployments.
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Cloudflare R2">
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { r2 } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: r2({
|
||||
binding: "MEDIA_BUCKET",
|
||||
publicUrl: "https://media.example.com",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Requires an R2 bucket configured in `wrangler.jsonc`:
|
||||
|
||||
```jsonc title="wrangler.jsonc"
|
||||
{
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA_BUCKET",
|
||||
"bucket_name": "my-media-bucket",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="S3-Compatible">
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { s3 } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
storage: s3({
|
||||
endpoint: "https://s3.amazonaws.com",
|
||||
bucket: "my-media-bucket",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
region: "us-east-1",
|
||||
publicUrl: "https://media.example.com",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Works with Cloudflare R2 (via S3 API), MinIO, and other S3-compatible services.
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## How Uploads Work
|
||||
|
||||
EmDash uses signed URLs for secure uploads:
|
||||
|
||||
1. Client requests an upload URL from the API
|
||||
|
||||
2. Server generates a signed URL with expiration
|
||||
|
||||
3. Client uploads directly to storage using the signed URL
|
||||
|
||||
4. Server records the file metadata in the database
|
||||
|
||||
This approach keeps large files off your application server and enables direct uploads to cloud storage.
|
||||
|
||||
<Aside>
|
||||
R2 bindings do not support pre-signed URLs. When using the R2 binding adapter, uploads go through
|
||||
your Worker.
|
||||
</Aside>
|
||||
|
||||
## Organizing Media
|
||||
|
||||
### Folders
|
||||
|
||||
Create folders to organize your media:
|
||||
|
||||
1. Click **New Folder** in the media library
|
||||
|
||||
2. Enter a folder name
|
||||
|
||||
3. Click **Create**
|
||||
|
||||
4. Drag files into folders to organize them
|
||||
|
||||
### Search
|
||||
|
||||
Use the search box to find files by name. Search matches partial filenames.
|
||||
|
||||
### Filters
|
||||
|
||||
Filter media by:
|
||||
|
||||
- **Type** - Images, Documents, Video, Audio
|
||||
- **Date** - Upload date range
|
||||
- **Folder** - Specific folder
|
||||
|
||||
## Using Media in Content
|
||||
|
||||
### In the Rich Text Editor
|
||||
|
||||
1. Place your cursor where you want the image
|
||||
|
||||
2. Click the image button in the toolbar
|
||||
|
||||
3. Select an image from the media library or upload a new one
|
||||
|
||||
4. Enter alt text
|
||||
|
||||
5. Click **Insert**
|
||||
|
||||
### As a Featured Image
|
||||
|
||||
1. Open a content entry in the editor
|
||||
|
||||
2. Find the **Featured Image** field in the sidebar
|
||||
|
||||
3. Click **Select Image**
|
||||
|
||||
4. Choose from the media library or upload
|
||||
|
||||
5. Click **Save**
|
||||
|
||||
### In Custom Fields
|
||||
|
||||
For fields configured as image or file types, click the field to open the media picker.
|
||||
|
||||
## Displaying Media in Templates
|
||||
|
||||
Access media URLs from your content data:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
---
|
||||
|
||||
{post?.data.featured_image && (
|
||||
<img
|
||||
src={post.data.featured_image}
|
||||
alt={post.data.featured_image_alt ?? ""}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
### Responsive Images
|
||||
|
||||
For responsive images, use Astro's Image component with external URLs:
|
||||
|
||||
### Responsive Images
|
||||
|
||||
For responsive images, use Astro's Image component with external URLs:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Image } from "astro:assets";
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
---
|
||||
|
||||
{post?.data.featured_image && (
|
||||
<Image
|
||||
src={post.data.featured_image}
|
||||
alt={post.data.featured_image_alt ?? ""}
|
||||
width={800}
|
||||
height={450}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Configure allowed image domains in `astro.config.mjs` to use the Image component with external URLs:
|
||||
|
||||
```js
|
||||
export default defineConfig({
|
||||
image: {
|
||||
domains: ["media.example.com"],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
</Aside>
|
||||
|
||||
## Deleting Media
|
||||
|
||||
1. Select the file(s) you want to delete
|
||||
|
||||
2. Click **Delete**
|
||||
|
||||
3. Confirm the deletion
|
||||
|
||||
<Aside type="caution">
|
||||
Deleting media does not remove references in your content. Ensure you update or remove content
|
||||
that uses deleted files.
|
||||
</Aside>
|
||||
|
||||
## Media API
|
||||
|
||||
Access media programmatically using the admin API.
|
||||
|
||||
### Upload a File
|
||||
|
||||
Upload media as multipart form data:
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/media
|
||||
Content-Type: multipart/form-data
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
file=<binary file data>
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"item": {
|
||||
"id": "01ABC123",
|
||||
"filename": "hero-image.jpg",
|
||||
"mime_type": "image/jpeg",
|
||||
"storage_key": "media/abc123/hero-image.jpg",
|
||||
"width": 1200,
|
||||
"height": 800
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### List Media
|
||||
|
||||
```bash
|
||||
GET /_emdash/api/media?prefix=images/&limit=20
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
### Delete Media
|
||||
|
||||
```bash
|
||||
DELETE /_emdash/api/media/images/hero.jpg
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
## Media Providers
|
||||
|
||||
In addition to local storage, EmDash supports external media providers for specialized image and video hosting. Media providers appear as tabs in the media picker, letting editors choose from multiple sources.
|
||||
|
||||
### Available Providers
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Cloudflare Images">
|
||||
[Cloudflare Images](https://developers.cloudflare.com/images/) provides image hosting with automatic optimization, resizing, and format conversion.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { cloudflareImages } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
// ... database, storage config
|
||||
mediaProviders: [
|
||||
cloudflareImages({
|
||||
accountId: import.meta.env.CF_ACCOUNT_ID,
|
||||
apiToken: import.meta.env.CF_IMAGES_TOKEN,
|
||||
// Optional: custom delivery domain
|
||||
deliveryDomain: "images.example.com",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Browse and upload images directly from the admin
|
||||
- Automatic image optimization and format conversion
|
||||
- URL-based transformations (resize, crop, format)
|
||||
- Flexible variants for responsive images
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Cloudflare Stream">
|
||||
[Cloudflare Stream](https://developers.cloudflare.com/stream/) provides video hosting with HLS/DASH adaptive streaming.
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { cloudflareStream } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
// ... database, storage config
|
||||
mediaProviders: [
|
||||
cloudflareStream({
|
||||
accountId: import.meta.env.CF_ACCOUNT_ID,
|
||||
apiToken: import.meta.env.CF_STREAM_TOKEN,
|
||||
// Optional: player settings
|
||||
controls: true,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Features:**
|
||||
- Browse, search, and upload videos from the admin
|
||||
- HLS and DASH adaptive streaming
|
||||
- Automatic thumbnail generation
|
||||
- Direct upload for large files
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Using Multiple Providers
|
||||
|
||||
You can configure multiple providers. Each appears as a tab in the media picker:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash/astro";
|
||||
import { cloudflareImages, cloudflareStream } from "@emdash-cms/cloudflare";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: d1({ binding: "DB" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
mediaProviders: [
|
||||
cloudflareImages({
|
||||
accountId: import.meta.env.CF_ACCOUNT_ID,
|
||||
apiToken: import.meta.env.CF_IMAGES_TOKEN,
|
||||
}),
|
||||
cloudflareStream({
|
||||
accountId: import.meta.env.CF_ACCOUNT_ID,
|
||||
apiToken: import.meta.env.CF_STREAM_TOKEN,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
The local media library ("Library" tab) is always available alongside any configured providers.
|
||||
|
||||
### Rendering Provider Media
|
||||
|
||||
Use the `Image` component to render media:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { Image } from "emdash/ui";
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
||||
---
|
||||
|
||||
{post?.data.featured_image && (
|
||||
<Image
|
||||
image={post.data.featured_image}
|
||||
width={800}
|
||||
height={450}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
The component automatically:
|
||||
- Detects the provider from the stored value
|
||||
- Renders an optimized `<img>` element
|
||||
- Applies provider-specific optimizations (e.g., Cloudflare Images transformations)
|
||||
|
||||
### MediaValue Type
|
||||
|
||||
Media fields store a `MediaValue` object containing provider information:
|
||||
|
||||
```ts
|
||||
interface MediaValue {
|
||||
provider?: string; // Provider ID, defaults to "local"
|
||||
id: string; // Provider-specific ID
|
||||
src?: string; // Direct URL (for local media or legacy data)
|
||||
previewUrl?: string; // Preview URL for admin display (external providers)
|
||||
filename?: string; // Original filename
|
||||
mimeType?: string; // MIME type
|
||||
width?: number; // Image/video width
|
||||
height?: number; // Image/video height
|
||||
alt?: string; // Alt text
|
||||
meta?: Record<string, unknown>; // Provider-specific metadata
|
||||
}
|
||||
```
|
||||
|
||||
This allows EmDash to render media correctly regardless of where it's hosted.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Working with Content](/guides/working-with-content/) - Use media in your content
|
||||
- [Create a Blog](/guides/create-a-blog/) - Add images to blog posts
|
||||
- [Querying Content](/guides/querying-content/) - Display media in templates
|
||||
281
docs/src/content/docs/guides/menus.mdx
Normal file
@@ -0,0 +1,281 @@
|
||||
---
|
||||
title: Navigation Menus
|
||||
description: Create and manage navigation menus for headers, footers, and sidebars.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem, Code } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash menus are ordered lists of links that you manage through the admin interface. Menus support nesting for dropdowns and can link to pages, posts, taxonomy terms, or external URLs.
|
||||
|
||||
## Querying Menus
|
||||
|
||||
Use `getMenu()` to fetch a menu by its unique name:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
const primaryMenu = await getMenu("primary");
|
||||
---
|
||||
|
||||
{primaryMenu && (
|
||||
<nav>
|
||||
<ul>
|
||||
{primaryMenu.items.map(item => (
|
||||
<li>
|
||||
<a href={item.url}>{item.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
```
|
||||
|
||||
The function returns `null` if no menu exists with that name.
|
||||
|
||||
## Menu Structure
|
||||
|
||||
A menu contains metadata and an array of items:
|
||||
|
||||
```ts
|
||||
interface Menu {
|
||||
id: string;
|
||||
name: string; // Unique identifier ("primary", "footer")
|
||||
label: string; // Display name ("Primary Navigation")
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string; // Resolved URL
|
||||
target?: string; // "_blank" for new window
|
||||
titleAttr?: string; // HTML title attribute
|
||||
cssClasses?: string; // Custom CSS classes
|
||||
children: MenuItem[]; // Nested items for dropdowns
|
||||
}
|
||||
```
|
||||
|
||||
URLs are resolved automatically based on the item type:
|
||||
|
||||
- **Page/Post items** resolve to `/{collection}/{slug}`
|
||||
- **Taxonomy items** resolve to `/{taxonomy}/{slug}`
|
||||
- **Collection items** resolve to `/{collection}/`
|
||||
- **Custom links** use the URL as-is
|
||||
|
||||
## Rendering Nested Menus
|
||||
|
||||
Menu items can have children for dropdown navigation. Handle nesting by recursively rendering the `children` array:
|
||||
|
||||
```astro title="src/components/Navigation.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
import type { MenuItem } from "emdash";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const menu = await getMenu(Astro.props.name);
|
||||
---
|
||||
|
||||
{menu && (
|
||||
<nav class="nav">
|
||||
<ul class="nav-list">
|
||||
{menu.items.map(item => (
|
||||
<li class:list={["nav-item", item.cssClasses]}>
|
||||
<a
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
title={item.titleAttr}
|
||||
aria-current={Astro.url.pathname === item.url ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{item.children.length > 0 && (
|
||||
<ul class="submenu">
|
||||
{item.children.map(child => (
|
||||
<li>
|
||||
<a href={child.url} target={child.target}>
|
||||
{child.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
```
|
||||
|
||||
<Aside>
|
||||
Use `aria-current="page"` to indicate the current page in navigation. Screen readers announce
|
||||
this, and the `[aria-current="page"]` CSS selector enables styling the active link.
|
||||
</Aside>
|
||||
|
||||
## Menu Item Types
|
||||
|
||||
The admin supports five types of menu items:
|
||||
|
||||
| Type | Description | URL Resolution |
|
||||
| ------------ | ---------------------------- | ---------------------- |
|
||||
| `page` | Link to a page | `/{collection}/{slug}` |
|
||||
| `post` | Link to a post | `/{collection}/{slug}` |
|
||||
| `taxonomy` | Link to a category or tag | `/{taxonomy}/{slug}` |
|
||||
| `collection` | Link to a collection archive | `/{collection}/` |
|
||||
| `custom` | External or custom URL | Used as-is |
|
||||
|
||||
## Listing All Menus
|
||||
|
||||
Use `getMenus()` to retrieve all menu definitions (without items):
|
||||
|
||||
```ts
|
||||
import { getMenus } from "emdash";
|
||||
|
||||
const menus = await getMenus();
|
||||
// Returns: [{ id, name, label }, ...]
|
||||
```
|
||||
|
||||
This is primarily useful for admin interfaces or debugging.
|
||||
|
||||
## Creating Menus
|
||||
|
||||
Create menus through the admin interface at `/_emdash/admin/menus`, or use the admin API:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/menus
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "footer",
|
||||
"label": "Footer Navigation"
|
||||
}
|
||||
```
|
||||
|
||||
Add items to a menu:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/menus/footer/items
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "page",
|
||||
"referenceCollection": "pages",
|
||||
"referenceId": "page_privacy",
|
||||
"label": "Privacy Policy"
|
||||
}
|
||||
```
|
||||
|
||||
Add a custom external link:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/menus/footer/items
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "custom",
|
||||
"customUrl": "https://github.com/example",
|
||||
"label": "GitHub",
|
||||
"target": "_blank"
|
||||
}
|
||||
```
|
||||
|
||||
## Reordering and Nesting
|
||||
|
||||
Update item order and parent-child relationships with the reorder endpoint:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/menus/primary/reorder
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"items": [
|
||||
{ "id": "item_1", "parentId": null, "sortOrder": 0 },
|
||||
{ "id": "item_2", "parentId": null, "sortOrder": 1 },
|
||||
{ "id": "item_3", "parentId": "item_2", "sortOrder": 0 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This makes `item_3` a child of `item_2`, creating a dropdown.
|
||||
|
||||
## Complete Example
|
||||
|
||||
The following example shows a responsive header with primary navigation:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getMenu, getSiteSettings } from "emdash";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const primaryMenu = await getMenu("primary");
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{settings.title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<a href="/" class="logo">
|
||||
{settings.logo ? (
|
||||
<img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
|
||||
) : (
|
||||
settings.title
|
||||
)}
|
||||
</a>
|
||||
|
||||
{primaryMenu && (
|
||||
<nav class="main-nav" aria-label="Main navigation">
|
||||
<ul>
|
||||
{primaryMenu.items.map(item => (
|
||||
<li class:list={[item.cssClasses, { "has-children": item.children.length > 0 }]}>
|
||||
<a
|
||||
href={item.url}
|
||||
target={item.target}
|
||||
aria-current={Astro.url.pathname === item.url ? "page" : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
{item.children.length > 0 && (
|
||||
<ul class="dropdown">
|
||||
{item.children.map(child => (
|
||||
<li>
|
||||
<a href={child.url} target={child.target}>{child.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getMenu(name)`
|
||||
|
||||
Fetch a menu by name with all items and resolved URLs.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `name` — The menu's unique identifier (string)
|
||||
|
||||
**Returns:** `Promise<Menu | null>`
|
||||
|
||||
### `getMenus()`
|
||||
|
||||
List all menu definitions without items.
|
||||
|
||||
**Returns:** `Promise<Array<{ id: string; name: string; label: string }>>`
|
||||
144
docs/src/content/docs/guides/page-layouts.mdx
Normal file
@@ -0,0 +1,144 @@
|
||||
---
|
||||
title: Page Layouts
|
||||
description: Let editors choose different layouts for individual pages using a template field.
|
||||
---
|
||||
|
||||
import { Aside } from '@astrojs/starlight/components';
|
||||
|
||||
WordPress has "Page Templates" — a dropdown in the editor that lets you pick a layout per page (e.g. Default, Full Width, Landing Page). EmDash supports the same pattern using a `select` field.
|
||||
|
||||
## How it works
|
||||
|
||||
1. Add a `template` select field to your pages collection
|
||||
2. Create layout components for each option
|
||||
3. Map the field value to a layout in your page route
|
||||
|
||||
No special system is needed — this uses EmDash's existing select field and Astro's component model.
|
||||
|
||||
## Add the field
|
||||
|
||||
In the admin UI, add a select field to your pages collection with slug `template` and your layout options (e.g. "Default", "Full Width"). Or include it in your seed data:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"slug": "template",
|
||||
"label": "Template",
|
||||
"type": "select",
|
||||
"validation": {
|
||||
"options": ["Default", "Full Width"]
|
||||
},
|
||||
"defaultValue": "Default"
|
||||
}
|
||||
```
|
||||
|
||||
## Create layout components
|
||||
|
||||
Each layout wraps content in your base layout with different styling:
|
||||
|
||||
```astro title="src/layouts/PageDefault.astro"
|
||||
---
|
||||
import type { ContentEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "./Base.astro";
|
||||
|
||||
interface Props {
|
||||
page: ContentEntry<any>;
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
---
|
||||
|
||||
<Base title={page.data.title}>
|
||||
<article class="page-default">
|
||||
<h1>{page.data.title}</h1>
|
||||
<PortableText value={page.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.page-default {
|
||||
max-width: var(--content-width);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
```astro title="src/layouts/PageFullWidth.astro"
|
||||
---
|
||||
import type { ContentEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "./Base.astro";
|
||||
|
||||
interface Props {
|
||||
page: ContentEntry<any>;
|
||||
}
|
||||
|
||||
const { page } = Astro.props;
|
||||
---
|
||||
|
||||
<Base title={page.data.title}>
|
||||
<article class="page-wide">
|
||||
<h1>{page.data.title}</h1>
|
||||
<PortableText value={page.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.page-wide {
|
||||
max-width: var(--wide-width);
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Wire up the route
|
||||
|
||||
In your page route, import each layout and map the template value:
|
||||
|
||||
```astro title="src/pages/pages/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import PageDefault from "../../layouts/PageDefault.astro";
|
||||
import PageFullWidth from "../../layouts/PageFullWidth.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
if (!slug) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const { entry: page } = await getEmDashEntry("pages", slug);
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const layouts = {
|
||||
"Default": PageDefault,
|
||||
"Full Width": PageFullWidth,
|
||||
};
|
||||
|
||||
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
|
||||
---
|
||||
|
||||
<Layout page={page} />
|
||||
```
|
||||
|
||||
The route stays small. Each layout component owns its own markup and styling. Adding a layout is: create a component, add the option to the select field, add a line to the map.
|
||||
|
||||
<Aside type="tip">
|
||||
Use human-readable option names like "Full Width" rather than slugs like "full-width". The value is both the stored value and the admin dropdown label.
|
||||
</Aside>
|
||||
|
||||
## Adding more layouts
|
||||
|
||||
Common choices from WordPress themes:
|
||||
|
||||
- **Default** — narrow content column, good for reading
|
||||
- **Full Width** — wider content area, no sidebar
|
||||
- **Landing Page** — no header/footer, hero sections
|
||||
- **Sidebar** — content with a sidebar widget area
|
||||
|
||||
Each is just another Astro component in your `src/layouts/` directory and another entry in the route's layout map.
|
||||
384
docs/src/content/docs/guides/preview.mdx
Normal file
@@ -0,0 +1,384 @@
|
||||
---
|
||||
title: Preview Mode
|
||||
description: Enable secure previews of draft content before publishing.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's preview system lets editors view unpublished content through secure, time-limited URLs. Preview links use HMAC-SHA256 signed tokens that you can share with reviewers without exposing your entire draft content.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The admin generates a preview URL for a draft post
|
||||
2. The URL contains a signed `_preview` query parameter with an expiration time
|
||||
3. EmDash's middleware automatically verifies the token and sets up the request context
|
||||
4. Your template code calls `getEmDashEntry()` as normal — draft content is served automatically
|
||||
|
||||
Preview is **implicit**. Your template code doesn't need to handle tokens or pass preview options — the middleware and query functions handle everything via `AsyncLocalStorage`.
|
||||
|
||||
## Setting Up Preview
|
||||
|
||||
Preview works out of the box — EmDash auto-generates a per-site preview
|
||||
secret on first use and stores it in the database. No env vars required
|
||||
for the common case.
|
||||
|
||||
Set `EMDASH_PREVIEW_SECRET` in your environment only if you need to:
|
||||
|
||||
- Share the secret across multiple processes (e.g. a separate preview
|
||||
Worker that signs URLs and sends them to your main site for verification)
|
||||
- Pin the secret to a value you control for compliance/audit reasons
|
||||
- Migrate to a known value when restoring from a backup
|
||||
|
||||
```bash title=".env"
|
||||
# Optional: override the auto-generated secret
|
||||
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"
|
||||
```
|
||||
|
||||
If set, the env value wins over the DB-stored value.
|
||||
|
||||
That's it. Your existing templates work with preview automatically:
|
||||
|
||||
```astro title="src/pages/posts/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
// No special preview handling needed — the middleware
|
||||
// detects _preview tokens and serves draft content automatically
|
||||
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
{isPreview && (
|
||||
<div class="preview-banner">
|
||||
You are viewing a preview. This content is not published.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<article>
|
||||
<h1>{entry.data.title}</h1>
|
||||
</article>
|
||||
```
|
||||
|
||||
The `isPreview` flag is `true` when draft content is being served via a valid preview token.
|
||||
|
||||
## Generating Preview URLs
|
||||
|
||||
Use `getPreviewUrl()` to create preview links. The function takes the
|
||||
secret as an explicit argument:
|
||||
|
||||
```ts
|
||||
import { getPreviewUrl } from "emdash";
|
||||
|
||||
const previewUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
expiresIn: "1h",
|
||||
});
|
||||
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...
|
||||
```
|
||||
|
||||
When `EMDASH_PREVIEW_SECRET` isn't set, EmDash auto-generates and stores
|
||||
a per-site secret in the database for token verification. The
|
||||
`getPreviewUrl()` template helper still requires you to pass the secret
|
||||
explicitly — pin your env var if you call it from page templates. Most
|
||||
sites use the admin UI's "Generate preview link" button instead, which
|
||||
goes through the API and uses the resolved secret automatically.
|
||||
|
||||
With a base URL for absolute links:
|
||||
|
||||
```ts
|
||||
const fullUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
baseUrl: "https://example.com",
|
||||
});
|
||||
// Returns: https://example.com/posts/my-draft-post?_preview=eyJjaWQ...
|
||||
```
|
||||
|
||||
With a custom path pattern:
|
||||
|
||||
```ts
|
||||
const blogUrl = await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "my-draft-post",
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
pathPattern: "/blog/{id}",
|
||||
});
|
||||
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...
|
||||
```
|
||||
|
||||
### Locale-aware paths
|
||||
|
||||
`pathPattern` also supports a `{locale}` placeholder. Pass an empty `locale`
|
||||
when the entry is in the default locale and `prefixDefaultLocale` is `false`;
|
||||
adjacent slashes left by the empty value are collapsed automatically.
|
||||
|
||||
```ts
|
||||
await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello",
|
||||
secret,
|
||||
pathPattern: "/{locale}/{id}",
|
||||
locale: "pt-br",
|
||||
});
|
||||
// Returns: /pt-br/hello?_preview=...
|
||||
|
||||
await getPreviewUrl({
|
||||
collection: "posts",
|
||||
id: "hello",
|
||||
secret,
|
||||
pathPattern: "/{locale}/{id}",
|
||||
locale: "", // default locale, no prefix
|
||||
});
|
||||
// Returns: /hello?_preview=...
|
||||
```
|
||||
|
||||
The admin's "View on site" link goes through `POST /_emdash/api/content/{collection}/{id}/preview-url`,
|
||||
which reads the entry's locale, looks up the site's i18n config and supplies
|
||||
the `locale` automatically. To change the default pattern used by that
|
||||
endpoint, set `EMDASH_PREVIEW_PATH_PATTERN` (e.g. `/{locale}/{id}`) — request
|
||||
bodies still win when they include their own `pathPattern`.
|
||||
|
||||
## Token Expiration
|
||||
|
||||
Control how long preview links remain valid:
|
||||
|
||||
```ts
|
||||
// Valid for 1 hour (default)
|
||||
await getPreviewUrl({ ..., expiresIn: "1h" });
|
||||
|
||||
// Valid for 30 minutes
|
||||
await getPreviewUrl({ ..., expiresIn: "30m" });
|
||||
|
||||
// Valid for 1 day
|
||||
await getPreviewUrl({ ..., expiresIn: "1d" });
|
||||
|
||||
// Valid for 2 weeks
|
||||
await getPreviewUrl({ ..., expiresIn: "2w" });
|
||||
|
||||
// Valid for 3600 seconds
|
||||
await getPreviewUrl({ ..., expiresIn: 3600 });
|
||||
```
|
||||
|
||||
Supported units: `s` (seconds), `m` (minutes), `h` (hours), `d` (days), `w` (weeks).
|
||||
|
||||
## Verifying Tokens
|
||||
|
||||
Use `verifyPreviewToken()` to validate incoming preview requests:
|
||||
|
||||
```ts
|
||||
import { verifyPreviewToken } from "emdash";
|
||||
|
||||
// From a URL (extracts _preview query parameter)
|
||||
const result = await verifyPreviewToken({
|
||||
url: Astro.url,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
});
|
||||
|
||||
// Or with a token directly
|
||||
const result = await verifyPreviewToken({
|
||||
token: someTokenString,
|
||||
secret: import.meta.env.EMDASH_PREVIEW_SECRET,
|
||||
});
|
||||
```
|
||||
|
||||
The result indicates whether the token is valid:
|
||||
|
||||
```ts
|
||||
if (result.valid) {
|
||||
// Token is valid
|
||||
console.log(result.payload.cid); // "posts:my-draft-post"
|
||||
console.log(result.payload.exp); // Expiry timestamp
|
||||
console.log(result.payload.iat); // Issued-at timestamp
|
||||
} else {
|
||||
// Token is invalid
|
||||
console.log(result.error);
|
||||
// "none" - no token present
|
||||
// "malformed" - token structure is invalid
|
||||
// "invalid" - signature verification failed
|
||||
// "expired" - token has expired
|
||||
}
|
||||
```
|
||||
|
||||
## Preview Indicator
|
||||
|
||||
You can show a visual indicator when content is being previewed. The `isPreview` flag returned by `getEmDashEntry` tells you when draft content is being served:
|
||||
|
||||
```astro
|
||||
{isPreview && (
|
||||
<div class="preview-banner" role="alert">
|
||||
<strong>Preview</strong> — You are viewing unpublished content.
|
||||
<a href={Astro.url.pathname}>Exit preview</a>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
For authenticated editors using visual editing, EmDash automatically injects a floating toolbar
|
||||
that indicates edit/preview mode. You only need a custom preview banner for shared preview links.
|
||||
</Aside>
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### `isPreviewRequest(url)`
|
||||
|
||||
Check if a URL contains a preview token:
|
||||
|
||||
```ts
|
||||
import { isPreviewRequest } from "emdash";
|
||||
|
||||
if (isPreviewRequest(Astro.url)) {
|
||||
// Handle preview request
|
||||
}
|
||||
```
|
||||
|
||||
### `getPreviewToken(url)`
|
||||
|
||||
Extract the token string from a URL:
|
||||
|
||||
```ts
|
||||
import { getPreviewToken } from "emdash";
|
||||
|
||||
const token = getPreviewToken(Astro.url);
|
||||
// Returns the token string or null
|
||||
```
|
||||
|
||||
### `parseContentId(contentId)`
|
||||
|
||||
Parse a content ID into collection and ID:
|
||||
|
||||
```ts
|
||||
import { parseContentId } from "emdash";
|
||||
|
||||
const { collection, id } = parseContentId("posts:my-draft-post");
|
||||
// { collection: "posts", id: "my-draft-post" }
|
||||
```
|
||||
|
||||
## Token Format
|
||||
|
||||
Preview tokens use a compact format: `base64url(payload).base64url(signature)`
|
||||
|
||||
The payload contains:
|
||||
|
||||
- `cid` — Content ID in format `collection:id`
|
||||
- `exp` — Expiry timestamp (seconds since epoch)
|
||||
- `iat` — Issued-at timestamp (seconds since epoch)
|
||||
|
||||
Tokens are signed with HMAC-SHA256 using your preview secret.
|
||||
|
||||
<Aside type="caution">
|
||||
Keep your `EMDASH_PREVIEW_SECRET` secure. Anyone with this secret can generate valid preview tokens for
|
||||
any content.
|
||||
</Aside>
|
||||
|
||||
## Complete Example
|
||||
|
||||
A full blog post page with preview and visual editing support:
|
||||
|
||||
```astro title="src/pages/posts/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import BaseLayout from "../../layouts/Base.astro";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
// Preview is automatic — middleware handles token verification
|
||||
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<BaseLayout title={entry.data.title}>
|
||||
{isPreview && (
|
||||
<div class="preview-banner" role="alert">
|
||||
<strong>Preview</strong> — This content is not published.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<article {...entry.edit}>
|
||||
<header>
|
||||
<h1 {...entry.edit.title}>{entry.data.title}</h1>
|
||||
{entry.data.publishedAt && (
|
||||
<time datetime={entry.data.publishedAt.toISOString()}>
|
||||
{entry.data.publishedAt.toLocaleDateString()}
|
||||
</time>
|
||||
)}
|
||||
{isPreview && !entry.data.publishedAt && (
|
||||
<span class="draft-indicator">Draft</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div class="content" {...entry.edit.content}>
|
||||
<PortableText value={entry.data.content} />
|
||||
</div>
|
||||
</article>
|
||||
</BaseLayout>
|
||||
```
|
||||
|
||||
Note the `{...entry.edit}` and `{...entry.edit.title}` spreads — these add `data-emdash-ref` attributes that enable visual editing for authenticated editors. In production, they produce no output.
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getPreviewUrl(options)`
|
||||
|
||||
Generate a preview URL with a signed token.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `collection` — Collection slug (string)
|
||||
- `id` — Content ID or slug (string)
|
||||
- `secret` — Signing secret (string)
|
||||
- `expiresIn` — Token validity duration (default: `"1h"`)
|
||||
- `baseUrl` — Optional base URL for absolute links
|
||||
- `pathPattern` — URL pattern with `{collection}`, `{id}` and `{locale}` placeholders (default: `"/{collection}/{id}"`)
|
||||
- `locale` — Value substituted for `{locale}`. Empty string omits the locale segment (slashes are collapsed).
|
||||
|
||||
**Returns:** `Promise<string>`
|
||||
|
||||
### `verifyPreviewToken(options)`
|
||||
|
||||
Verify a preview token.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `secret` — Verification secret (string)
|
||||
- `url` — URL to extract token from, OR
|
||||
- `token` — Token string directly
|
||||
|
||||
**Returns:** `Promise<VerifyPreviewTokenResult>`
|
||||
|
||||
```ts
|
||||
type VerifyPreviewTokenResult =
|
||||
| { valid: true; payload: PreviewTokenPayload }
|
||||
| { valid: false; error: "invalid" | "expired" | "malformed" | "none" };
|
||||
```
|
||||
|
||||
### `generatePreviewToken(options)`
|
||||
|
||||
Generate a token without building a URL.
|
||||
|
||||
**Options:**
|
||||
|
||||
- `contentId` — Content ID in format `collection:id`
|
||||
- `expiresIn` — Token validity duration (default: `"1h"`)
|
||||
- `secret` — Signing secret
|
||||
|
||||
**Returns:** `Promise<string>`
|
||||
411
docs/src/content/docs/guides/querying-content.mdx
Normal file
@@ -0,0 +1,411 @@
|
||||
---
|
||||
title: Querying Content
|
||||
description: Use getEmDashCollection and getEmDashEntry to retrieve content in your templates.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash provides query functions to retrieve content in your Astro pages and components. These functions follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning structured results with error handling.
|
||||
|
||||
## Query Functions
|
||||
|
||||
EmDash exports two primary query functions:
|
||||
|
||||
| Function | Purpose | Returns |
|
||||
| ----------------------- | -------------------------------------- | ----------------------------- |
|
||||
| `getEmDashCollection` | Retrieve all entries of a content type | `{ entries, error }` |
|
||||
| `getEmDashEntry` | Retrieve a single entry by ID or slug | `{ entry, error, isPreview }` |
|
||||
|
||||
Import them from `emdash`:
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
```
|
||||
|
||||
## Get All Entries
|
||||
|
||||
Use `getEmDashCollection` to retrieve all entries of a content type:
|
||||
|
||||
```astro title="src/pages/posts.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts, error } = await getEmDashCollection("posts");
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to load posts:", error);
|
||||
}
|
||||
---
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>{post.data.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
<Aside>
|
||||
The destructuring syntax `{ entries: posts }` renames `entries` to `posts` for cleaner code. Using `{ entries }` directly also works.
|
||||
</Aside>
|
||||
|
||||
### Filter by Locale
|
||||
|
||||
When [i18n is enabled](/guides/internationalization/), filter by locale to retrieve content in a specific language:
|
||||
|
||||
```ts
|
||||
// French posts
|
||||
const { entries: frenchPosts } = await getEmDashCollection("posts", {
|
||||
locale: "fr",
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Use the current request locale
|
||||
const { entries: localizedPosts } = await getEmDashCollection("posts", {
|
||||
locale: Astro.currentLocale,
|
||||
status: "published",
|
||||
});
|
||||
```
|
||||
|
||||
For single entries, pass `locale` as the third argument:
|
||||
|
||||
```ts
|
||||
const { entry: post } = await getEmDashEntry("posts", "my-post", {
|
||||
locale: Astro.currentLocale,
|
||||
});
|
||||
```
|
||||
|
||||
When `locale` is omitted, it defaults to the request's current locale. If no translation exists for the requested locale, the [fallback chain](/guides/internationalization/#fallback-chain) is followed.
|
||||
|
||||
### Filter by Status
|
||||
|
||||
Retrieve only published or draft content:
|
||||
|
||||
```ts
|
||||
// Only published posts
|
||||
const { entries: published } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Only drafts
|
||||
const { entries: drafts } = await getEmDashCollection("posts", {
|
||||
status: "draft",
|
||||
});
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Always filter by `status: "published"` for public-facing pages. Draft content should only be
|
||||
accessible in the admin or preview mode.
|
||||
</Aside>
|
||||
|
||||
### Limit Results
|
||||
|
||||
Restrict the number of returned entries:
|
||||
|
||||
```ts
|
||||
// Get the 5 most recent posts
|
||||
const { entries: recentPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
limit: 5,
|
||||
});
|
||||
```
|
||||
|
||||
### Filter by Taxonomy
|
||||
|
||||
Filter entries by category, tag, or custom taxonomy terms:
|
||||
|
||||
```ts
|
||||
// Posts in the "news" category
|
||||
const { entries: newsPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: "news" },
|
||||
});
|
||||
|
||||
// Posts with the "javascript" tag
|
||||
const { entries: jsPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { tag: "javascript" },
|
||||
});
|
||||
|
||||
// Posts matching any of multiple terms
|
||||
const { entries: featuredNews } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: ["news", "featured"] },
|
||||
});
|
||||
```
|
||||
|
||||
The `where` filter uses OR logic when multiple values are provided for a single taxonomy.
|
||||
|
||||
### Error Handling
|
||||
|
||||
Always check for errors when reliability matters:
|
||||
|
||||
```ts
|
||||
const { entries: posts, error } = await getEmDashCollection("posts");
|
||||
|
||||
if (error) {
|
||||
// Log and handle gracefully
|
||||
console.error("Failed to load posts:", error);
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
```
|
||||
|
||||
## Get a Single Entry
|
||||
|
||||
Use `getEmDashEntry` to retrieve one entry by its ID or slug:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
</article>
|
||||
```
|
||||
|
||||
### Entry Return Type
|
||||
|
||||
`getEmDashEntry` returns a result object:
|
||||
|
||||
```ts
|
||||
interface EntryResult<T> {
|
||||
entry: ContentEntry<T> | null; // null if not found
|
||||
error?: Error; // Only set for actual errors (not "not found")
|
||||
isPreview: boolean; // true if viewing preview/draft content
|
||||
}
|
||||
|
||||
interface ContentEntry<T> {
|
||||
id: string;
|
||||
data: T;
|
||||
edit: EditProxy; // Visual editing annotations
|
||||
}
|
||||
```
|
||||
|
||||
The `data` object within `entry` contains all fields defined for the content type. The `edit` proxy provides visual editing annotations (see below).
|
||||
|
||||
## Preview Mode
|
||||
|
||||
EmDash handles preview automatically via middleware. When a URL contains a valid `_preview` token, the middleware verifies it and sets up the request context. Your query functions then serve draft content without any special parameters:
|
||||
|
||||
```astro title="src/pages/posts/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
|
||||
// No special preview handling needed — middleware does it automatically
|
||||
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!entry) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
{isPreview && (
|
||||
<div class="preview-banner">
|
||||
Viewing preview. This content is not published.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<article>
|
||||
<h1>{entry.data.title}</h1>
|
||||
<PortableText value={entry.data.content} />
|
||||
</article>
|
||||
```
|
||||
|
||||
<Aside>
|
||||
Preview works out of the box — EmDash auto-generates a per-site preview secret on first
|
||||
use. Set `EMDASH_PREVIEW_SECRET` in your environment if you need to share the secret across
|
||||
multiple processes (e.g. a separate preview Worker). The admin generates preview URLs that
|
||||
include an HMAC-signed `_preview` token either way.
|
||||
</Aside>
|
||||
|
||||
## Visual Editing
|
||||
|
||||
Every entry returned by query functions includes an `edit` proxy for annotating your templates. Spread it onto elements to enable inline editing for authenticated editors:
|
||||
|
||||
```astro
|
||||
<article {...entry.edit}>
|
||||
<h1 {...entry.edit.title}>{entry.data.title}</h1>
|
||||
<div {...entry.edit.content}>
|
||||
<PortableText value={entry.data.content} />
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
In edit mode, `{...entry.edit.title}` produces a `data-emdash-ref` attribute that the visual editing toolbar uses to enable inline editing. In production, the proxy spreads produce no output — zero runtime cost.
|
||||
|
||||
<Aside type="tip">
|
||||
For string and text fields, inline editing uses `contenteditable`. For Portable Text fields, it
|
||||
injects a TipTap editor. For image fields, it opens a media library popover.
|
||||
</Aside>
|
||||
|
||||
## Sorting Results
|
||||
|
||||
`getEmDashCollection` does not guarantee sort order. Sort results in your template:
|
||||
|
||||
```ts
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Sort by publication date, newest first
|
||||
const sorted = posts.sort(
|
||||
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0),
|
||||
);
|
||||
```
|
||||
|
||||
### Common Sort Patterns
|
||||
|
||||
```ts
|
||||
// Alphabetical by title
|
||||
posts.sort((a, b) => a.data.title.localeCompare(b.data.title));
|
||||
|
||||
// By custom order field
|
||||
posts.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
|
||||
|
||||
// Random order
|
||||
posts.sort(() => Math.random() - 0.5);
|
||||
```
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
Generate TypeScript types for your collections:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
|
||||
This creates `.emdash/types.ts` with interfaces for each collection. Use them for type safety:
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
import type { Post } from "../.emdash/types";
|
||||
|
||||
// Type-safe collection query
|
||||
const { entries: posts } = await getEmDashCollection<Post>("posts");
|
||||
// posts is ContentEntry<Post>[]
|
||||
|
||||
// Type-safe entry query
|
||||
const { entry: post } = await getEmDashEntry<Post>("posts", "my-post");
|
||||
// post is ContentEntry<Post> | null
|
||||
```
|
||||
|
||||
## Static vs. Server Rendering
|
||||
|
||||
EmDash content works with both static and server-rendered pages.
|
||||
|
||||
### Static (Prerendered)
|
||||
|
||||
For static pages, use `getStaticPaths` to generate routes at build time:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEmDashEntry } from "emdash";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.data.slug },
|
||||
}));
|
||||
}
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug);
|
||||
---
|
||||
```
|
||||
|
||||
### Server-Rendered
|
||||
|
||||
For server-rendered pages, query content directly:
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
export const prerender = false;
|
||||
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
---
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Server rendering shows content changes immediately without rebuilding. Use it for frequently
|
||||
updated content.
|
||||
</Aside>
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching
|
||||
|
||||
EmDash uses Astro's live content collections, which handle caching automatically. For server-rendered pages, consider adding HTTP cache headers:
|
||||
|
||||
```astro
|
||||
---
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Cache for 5 minutes
|
||||
Astro.response.headers.set("Cache-Control", "public, max-age=300");
|
||||
---
|
||||
```
|
||||
|
||||
### Avoid Redundant Queries
|
||||
|
||||
Query once and pass data to components:
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import PostList from "../components/PostList.astro";
|
||||
import Sidebar from "../components/Sidebar.astro";
|
||||
|
||||
// Query once
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
const featured = posts.filter((p) => p.data.featured);
|
||||
const recent = posts.slice(0, 5);
|
||||
---
|
||||
|
||||
<PostList posts={featured} />
|
||||
<Sidebar posts={recent} />
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Create a Blog](/guides/create-a-blog/) - Build a complete blog
|
||||
- [Taxonomies](/guides/taxonomies/) - Filter by categories and tags
|
||||
- [Working with Content](/guides/working-with-content/) - Admin CRUD operations
|
||||
- [Internationalization](/guides/internationalization/) - Multilingual content and translations
|
||||
264
docs/src/content/docs/guides/sections.mdx
Normal file
@@ -0,0 +1,264 @@
|
||||
---
|
||||
title: Sections
|
||||
description: Create and use reusable content blocks across your site.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Sections are reusable content blocks that editors can insert into any content via slash commands. Use them for common patterns like CTAs, testimonials, feature grids, or any content that appears across multiple pages.
|
||||
|
||||
## Querying Sections
|
||||
|
||||
### getSection
|
||||
|
||||
Fetch a single section by slug:
|
||||
|
||||
```typescript
|
||||
import { getSection } from "emdash";
|
||||
|
||||
const cta = await getSection("newsletter-cta");
|
||||
|
||||
if (cta) {
|
||||
console.log(cta.title); // "Newsletter CTA"
|
||||
console.log(cta.content); // PortableTextBlock[]
|
||||
}
|
||||
```
|
||||
|
||||
### getSections
|
||||
|
||||
Fetch multiple sections with optional filters:
|
||||
|
||||
```typescript
|
||||
import { getSections } from "emdash";
|
||||
|
||||
// Get all sections
|
||||
const { items: all } = await getSections();
|
||||
|
||||
// Filter by source
|
||||
const { items: themeSections } = await getSections({ source: "theme" });
|
||||
|
||||
// Search by title/keywords
|
||||
const { items: results } = await getSections({ search: "newsletter" });
|
||||
```
|
||||
|
||||
`getSections` returns `{ items: Section[], nextCursor?: string }` following the standard pagination pattern.
|
||||
|
||||
## Section Structure
|
||||
|
||||
```typescript
|
||||
interface Section {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
content: PortableTextBlock[];
|
||||
previewUrl?: string;
|
||||
source: "theme" | "user" | "import";
|
||||
themeId?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Source Types
|
||||
|
||||
| Source | Description |
|
||||
| -------- | ------------------------------------------------ |
|
||||
| `theme` | Defined in seed file, managed by theme |
|
||||
| `user` | Created by editors in admin |
|
||||
| `import` | Imported from WordPress (reusable blocks) |
|
||||
|
||||
## Using Sections in Content
|
||||
|
||||
Editors insert sections using the `/section` slash command in the rich text editor:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Type `/section` (or `/pattern`, `/block`, `/template`)
|
||||
|
||||
2. Search or browse available sections
|
||||
|
||||
3. Click to insert the section's content at the cursor position
|
||||
|
||||
</Steps>
|
||||
|
||||
The section's Portable Text content is copied into the document. This means:
|
||||
|
||||
- Changes to the section don't affect already-inserted content
|
||||
- Editors can customize the inserted content
|
||||
- Content remains self-contained
|
||||
|
||||
<Aside type="tip">
|
||||
For content that should stay synchronized, consider using [Widget Areas](/guides/widgets/) with component widgets instead.
|
||||
</Aside>
|
||||
|
||||
## Creating Sections
|
||||
|
||||
### In the Admin UI
|
||||
|
||||
1. Navigate to **Sections** in the admin sidebar
|
||||
|
||||
2. Click **New Section**
|
||||
|
||||
3. Fill in:
|
||||
- **Title** - Display name for the section
|
||||
- **Slug** - URL identifier (auto-generated from title)
|
||||
- **Description** - Help text for editors
|
||||
|
||||
4. Add content using the rich text editor
|
||||
|
||||
5. Optionally set keywords for easier discovery
|
||||
|
||||
### Via Seed Files
|
||||
|
||||
Include sections in your theme's seed file:
|
||||
|
||||
```json
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"slug": "hero-centered",
|
||||
"title": "Centered Hero",
|
||||
"description": "Full-width hero with centered heading and CTA",
|
||||
"keywords": ["hero", "banner", "header"],
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h1",
|
||||
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"children": [{ "_type": "span", "text": "Your tagline goes here." }]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "newsletter-cta",
|
||||
"title": "Newsletter CTA",
|
||||
"keywords": ["newsletter", "subscribe", "email"],
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Via WordPress Import
|
||||
|
||||
WordPress reusable blocks (`wp_block` post type) are automatically imported as sections:
|
||||
|
||||
- Source is set to `"import"`
|
||||
- Gutenberg content converted to Portable Text
|
||||
|
||||
## Rendering Sections Programmatically
|
||||
|
||||
For server-rendered section content outside the editor:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getSection } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
|
||||
const newsletter = await getSection("newsletter-cta");
|
||||
---
|
||||
|
||||
{newsletter && (
|
||||
<aside class="cta-box">
|
||||
<PortableText value={newsletter.content} />
|
||||
</aside>
|
||||
)}
|
||||
```
|
||||
|
||||
## Admin UI Features
|
||||
|
||||
The Sections library (`/_emdash/admin/sections`) provides:
|
||||
|
||||
- **Grid view** with section previews
|
||||
- **Search** by title and keywords
|
||||
- **Filter** by source
|
||||
- **Quick copy** slug to clipboard
|
||||
- **Edit** section content and metadata
|
||||
- **Delete** with confirmation (warns for theme sections)
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getSection(slug)`
|
||||
|
||||
Fetch a section by slug.
|
||||
|
||||
**Parameters:**
|
||||
- `slug` — The section's unique identifier (string)
|
||||
|
||||
**Returns:** `Promise<Section | null>`
|
||||
|
||||
### `getSections(options?)`
|
||||
|
||||
List sections with optional filters.
|
||||
|
||||
**Parameters:**
|
||||
- `options.source` — Filter by source: `"theme"`, `"user"`, or `"import"`
|
||||
- `options.search` — Search title and keywords
|
||||
|
||||
**Returns:** `Promise<Section[]>`
|
||||
|
||||
## REST API
|
||||
|
||||
### List Sections
|
||||
|
||||
```http
|
||||
GET /_emdash/api/sections
|
||||
GET /_emdash/api/sections?source=theme
|
||||
GET /_emdash/api/sections?search=newsletter
|
||||
```
|
||||
|
||||
### Get Section
|
||||
|
||||
```http
|
||||
GET /_emdash/api/sections/newsletter-cta
|
||||
```
|
||||
|
||||
### Create Section
|
||||
|
||||
```http
|
||||
POST /_emdash/api/sections
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"slug": "my-section",
|
||||
"title": "My Section",
|
||||
"description": "Optional description",
|
||||
"keywords": ["keyword1", "keyword2"],
|
||||
"content": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Update Section
|
||||
|
||||
```http
|
||||
PUT /_emdash/api/sections/my-section
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "Updated Title",
|
||||
"content": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Section
|
||||
|
||||
```http
|
||||
DELETE /_emdash/api/sections/my-section
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Working with Content](/guides/working-with-content/) - Learn about the rich text editor
|
||||
- [Widget Areas](/guides/widgets/) - For synchronized dynamic content
|
||||
- [Content Import](/migration/content-import/) - Import WordPress reusable blocks
|
||||
326
docs/src/content/docs/guides/site-settings.mdx
Normal file
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: Site Settings
|
||||
description: Configure global settings like site title, logo, and social links.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Site settings are global configuration values for your site: title, tagline, logo, social links, and display preferences. Administrators manage these through the admin interface, and you access them in your templates.
|
||||
|
||||
## Querying Settings
|
||||
|
||||
Use `getSiteSettings()` to fetch all site settings:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getSiteSettings } from "emdash";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>{settings.title}</title>
|
||||
{settings.favicon && (
|
||||
<link rel="icon" href={settings.favicon.url} />
|
||||
)}
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{settings.logo ? (
|
||||
<img src={settings.logo.url} alt={settings.logo.alt || settings.title} />
|
||||
) : (
|
||||
<span class="site-title">{settings.title}</span>
|
||||
)}
|
||||
{settings.tagline && <p class="tagline">{settings.tagline}</p>}
|
||||
</header>
|
||||
<slot />
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Available Settings
|
||||
|
||||
EmDash provides these core settings:
|
||||
|
||||
```ts
|
||||
interface SiteSettings {
|
||||
// Identity
|
||||
title: string;
|
||||
tagline?: string;
|
||||
logo?: MediaReference;
|
||||
favicon?: MediaReference;
|
||||
|
||||
// URLs
|
||||
url?: string;
|
||||
|
||||
// Display
|
||||
postsPerPage: number;
|
||||
dateFormat: string;
|
||||
timezone: string;
|
||||
|
||||
// Social
|
||||
social?: {
|
||||
twitter?: string;
|
||||
github?: string;
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
linkedin?: string;
|
||||
youtube?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface MediaReference {
|
||||
mediaId: string;
|
||||
alt?: string;
|
||||
url?: string; // Resolved URL (read-only)
|
||||
}
|
||||
```
|
||||
|
||||
## Fetching Individual Settings
|
||||
|
||||
Use `getSiteSetting()` to fetch a single setting by key:
|
||||
|
||||
```ts
|
||||
import { getSiteSetting } from "emdash";
|
||||
|
||||
const title = await getSiteSetting("title");
|
||||
// Returns: "My Site" or undefined
|
||||
|
||||
const logo = await getSiteSetting("logo");
|
||||
// Returns: { mediaId: "...", url: "/_emdash/api/media/file/..." }
|
||||
```
|
||||
|
||||
This is useful when you only need one or two values and want to avoid fetching everything.
|
||||
|
||||
## Using Settings in Components
|
||||
|
||||
### Site Header
|
||||
|
||||
```astro title="src/components/Header.astro"
|
||||
---
|
||||
import { getSiteSettings, getMenu } from "emdash";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const menu = await getMenu("primary");
|
||||
---
|
||||
|
||||
<header class="header">
|
||||
<a href="/" class="logo">
|
||||
{settings.logo ? (
|
||||
<img
|
||||
src={settings.logo.url}
|
||||
alt={settings.logo.alt || settings.title}
|
||||
width="150"
|
||||
height="50"
|
||||
/>
|
||||
) : (
|
||||
<span class="site-name">{settings.title}</span>
|
||||
)}
|
||||
</a>
|
||||
|
||||
{menu && (
|
||||
<nav>
|
||||
{menu.items.map(item => (
|
||||
<a href={item.url}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
```
|
||||
|
||||
### Social Links
|
||||
|
||||
```astro title="src/components/SocialLinks.astro"
|
||||
---
|
||||
import { getSiteSetting } from "emdash";
|
||||
|
||||
const social = await getSiteSetting("social");
|
||||
|
||||
const platforms = [
|
||||
{ key: "twitter", label: "Twitter", baseUrl: "https://twitter.com/" },
|
||||
{ key: "github", label: "GitHub", baseUrl: "https://github.com/" },
|
||||
{ key: "facebook", label: "Facebook", baseUrl: "https://facebook.com/" },
|
||||
{ key: "instagram", label: "Instagram", baseUrl: "https://instagram.com/" },
|
||||
{ key: "linkedin", label: "LinkedIn", baseUrl: "https://linkedin.com/in/" },
|
||||
{ key: "youtube", label: "YouTube", baseUrl: "https://youtube.com/@" },
|
||||
] as const;
|
||||
---
|
||||
|
||||
{social && (
|
||||
<div class="social-links">
|
||||
{platforms.map(({ key, label, baseUrl }) => (
|
||||
social[key] && (
|
||||
<a
|
||||
href={baseUrl + social[key]}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
aria-label={label}
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### SEO Meta Tags
|
||||
|
||||
```astro title="src/components/SEO.astro"
|
||||
---
|
||||
import { getSiteSettings } from "emdash";
|
||||
|
||||
interface Props {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const {
|
||||
title,
|
||||
description = settings.tagline,
|
||||
image,
|
||||
} = Astro.props;
|
||||
|
||||
const documentTitle = title
|
||||
? `${title} | ${settings.title}`
|
||||
: settings.title;
|
||||
const ogTitle = title ?? settings.title;
|
||||
---
|
||||
|
||||
<title>{documentTitle}</title>
|
||||
{description && <meta name="description" content={description} />}
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content={ogTitle} />
|
||||
{description && <meta property="og:description" content={description} />}
|
||||
{image && <meta property="og:image" content={image} />}
|
||||
{settings.url && <meta property="og:url" content={settings.url + Astro.url.pathname} />}
|
||||
|
||||
<!-- Twitter -->
|
||||
{settings.social?.twitter && (
|
||||
<meta name="twitter:site" content={settings.social.twitter} />
|
||||
)}
|
||||
<meta name="twitter:card" content={image ? "summary_large_image" : "summary"} />
|
||||
```
|
||||
|
||||
## Date Formatting
|
||||
|
||||
Use the `dateFormat` and `timezone` settings for consistent date display:
|
||||
|
||||
```astro title="src/components/PostDate.astro"
|
||||
---
|
||||
import { getSiteSetting } from "emdash";
|
||||
|
||||
interface Props {
|
||||
date: string;
|
||||
}
|
||||
|
||||
const { date } = Astro.props;
|
||||
const dateFormat = await getSiteSetting("dateFormat") || "MMMM d, yyyy";
|
||||
const timezone = await getSiteSetting("timezone") || "UTC";
|
||||
|
||||
// Format using Intl.DateTimeFormat or a library like date-fns
|
||||
const formatted = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: timezone,
|
||||
dateStyle: "long",
|
||||
}).format(new Date(date));
|
||||
---
|
||||
|
||||
<time datetime={date}>{formatted}</time>
|
||||
```
|
||||
|
||||
<Aside>
|
||||
The `dateFormat` setting uses a pattern string (e.g., "MMMM d, yyyy"). You may need a library like
|
||||
`date-fns` to parse and apply these patterns.
|
||||
</Aside>
|
||||
|
||||
## Admin API
|
||||
|
||||
Fetch settings programmatically:
|
||||
|
||||
```http
|
||||
GET /_emdash/api/settings
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "My EmDash Site",
|
||||
"tagline": "A modern CMS",
|
||||
"logo": {
|
||||
"mediaId": "med_123",
|
||||
"url": "/_emdash/api/media/file/abc123"
|
||||
},
|
||||
"postsPerPage": 10,
|
||||
"dateFormat": "MMMM d, yyyy",
|
||||
"timezone": "America/New_York",
|
||||
"social": {
|
||||
"twitter": "@handle",
|
||||
"github": "username"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Update settings (partial updates supported):
|
||||
|
||||
```http
|
||||
POST /_emdash/api/settings
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"title": "New Site Title",
|
||||
"tagline": "Updated tagline"
|
||||
}
|
||||
```
|
||||
|
||||
Only the provided fields are changed. Omitted fields retain their current values.
|
||||
|
||||
## Media References
|
||||
|
||||
The `logo` and `favicon` settings store media references. When you read them, EmDash resolves the `url` property automatically:
|
||||
|
||||
```ts
|
||||
const logo = await getSiteSetting("logo");
|
||||
// {
|
||||
// mediaId: "med_123",
|
||||
// alt: "Site logo",
|
||||
// url: "/_emdash/api/media/file/abc123"
|
||||
// }
|
||||
```
|
||||
|
||||
When updating via the API, provide only the `mediaId`:
|
||||
|
||||
```json
|
||||
{
|
||||
"logo": {
|
||||
"mediaId": "med_456",
|
||||
"alt": "New logo"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getSiteSettings()`
|
||||
|
||||
Fetch all site settings with resolved media URLs.
|
||||
|
||||
**Returns:** `Promise<Partial<SiteSettings>>`
|
||||
|
||||
Returns a partial object. Unset values are `undefined`.
|
||||
|
||||
### `getSiteSetting(key)`
|
||||
|
||||
Fetch a single setting by key.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `key` — The setting key (e.g., `"title"`, `"logo"`, `"social"`)
|
||||
|
||||
**Returns:** `Promise<SiteSettings[K] | undefined>`
|
||||
|
||||
Type-safe: the return type matches the key you request.
|
||||
458
docs/src/content/docs/guides/taxonomies.mdx
Normal file
@@ -0,0 +1,458 @@
|
||||
---
|
||||
title: Taxonomies
|
||||
description: Organize content with categories, tags, and custom taxonomies.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Taxonomies are classification systems for organizing content. EmDash includes built-in categories and tags, and supports custom taxonomies for specialized classification needs.
|
||||
|
||||
## Built-in Taxonomies
|
||||
|
||||
EmDash provides two default taxonomies:
|
||||
|
||||
| Taxonomy | Type | Description |
|
||||
| -------------- | ------------ | ----------------------------------------------------- |
|
||||
| **Categories** | Hierarchical | Nested classification with parent-child relationships |
|
||||
| **Tags** | Flat | Simple labels without hierarchy |
|
||||
|
||||
Both are available for the posts collection by default.
|
||||
|
||||
## Managing Terms
|
||||
|
||||
### Create a Term
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Admin Dashboard">
|
||||
|
||||
1. Go to the taxonomy page (e.g., `/_emdash/admin/taxonomies/category`)
|
||||
|
||||
2. Enter the term name in the **Add New** form
|
||||
|
||||
3. Optionally set:
|
||||
- **Slug** - URL identifier (auto-generated from name)
|
||||
- **Parent** - For hierarchical taxonomies
|
||||
- **Description** - Term description
|
||||
|
||||
4. Click **Add**
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Content Editor">
|
||||
|
||||
5. Open a content entry in the editor
|
||||
|
||||
6. Find the taxonomy panel in the sidebar
|
||||
|
||||
7. For categories: check the boxes for applicable terms, or click **+ Add New**
|
||||
|
||||
8. For tags: type tag names separated by commas
|
||||
|
||||
9. Save the content
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="API">
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/taxonomies/category/terms
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"slug": "tutorials",
|
||||
"label": "Tutorials",
|
||||
"parentId": "term_abc",
|
||||
"description": "How-to guides and tutorials"
|
||||
}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Edit a Term
|
||||
|
||||
1. Go to the taxonomy terms page
|
||||
|
||||
2. Click **Edit** next to the term
|
||||
|
||||
3. Update the name, slug, parent, or description
|
||||
|
||||
4. Click **Save**
|
||||
|
||||
### Delete a Term
|
||||
|
||||
1. Go to the taxonomy terms page
|
||||
|
||||
2. Click **Delete** next to the term
|
||||
|
||||
3. Confirm the deletion
|
||||
|
||||
<Aside type="caution">
|
||||
Deleting a term removes it from all associated content. Content is not deleted, only the term
|
||||
assignment.
|
||||
</Aside>
|
||||
|
||||
## Querying Taxonomies
|
||||
|
||||
EmDash provides functions to query taxonomy terms and filter content by term.
|
||||
|
||||
### Get All Terms
|
||||
|
||||
Retrieve all terms for a taxonomy:
|
||||
|
||||
```ts
|
||||
import { getTaxonomyTerms } from "emdash";
|
||||
|
||||
// Get all categories (returns tree structure)
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
|
||||
// Get all tags (returns flat list)
|
||||
const tags = await getTaxonomyTerms("tag");
|
||||
```
|
||||
|
||||
For hierarchical taxonomies, terms include a `children` array:
|
||||
|
||||
```ts
|
||||
interface TaxonomyTerm {
|
||||
id: string;
|
||||
name: string; // Taxonomy name ("category")
|
||||
slug: string; // Term slug ("news")
|
||||
label: string; // Display label ("News")
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
children: TaxonomyTerm[];
|
||||
count?: number; // Number of entries with this term
|
||||
}
|
||||
```
|
||||
|
||||
### Get a Single Term
|
||||
|
||||
```ts
|
||||
import { getTerm } from "emdash";
|
||||
|
||||
const category = await getTerm("category", "news");
|
||||
// Returns TaxonomyTerm or null
|
||||
```
|
||||
|
||||
### Get Terms for an Entry
|
||||
|
||||
```ts
|
||||
import { getEntryTerms } from "emdash";
|
||||
|
||||
// Get all categories for a post
|
||||
const categories = await getEntryTerms("posts", "post-123", "category");
|
||||
|
||||
// Get all tags for a post
|
||||
const tags = await getEntryTerms("posts", "post-123", "tag");
|
||||
```
|
||||
|
||||
### Filter Content by Term
|
||||
|
||||
Use `getEmDashCollection` with the `where` filter:
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
// Posts in the "news" category
|
||||
const { entries: newsPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: "news" },
|
||||
});
|
||||
|
||||
// Posts with the "javascript" tag
|
||||
const { entries: jsPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { tag: "javascript" },
|
||||
});
|
||||
```
|
||||
|
||||
Or use the convenience function:
|
||||
|
||||
```ts
|
||||
import { getEntriesByTerm } from "emdash";
|
||||
|
||||
const newsPosts = await getEntriesByTerm("posts", "category", "news");
|
||||
```
|
||||
|
||||
## Building Taxonomy Pages
|
||||
|
||||
### Category Archive
|
||||
|
||||
Create a page that lists posts in a category:
|
||||
|
||||
```astro title="src/pages/category/[slug].astro"
|
||||
---
|
||||
import { getTaxonomyTerms, getTerm, getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
|
||||
// Flatten hierarchical tree for routing
|
||||
function flatten(terms) {
|
||||
return terms.flatMap((term) => [term, ...flatten(term.children)]);
|
||||
}
|
||||
|
||||
return flatten(categories).map((cat) => ({
|
||||
params: { slug: cat.slug },
|
||||
props: { category: cat },
|
||||
}));
|
||||
}
|
||||
|
||||
const { category } = Astro.props;
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: category.slug },
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={category.label}>
|
||||
<h1>{category.label}</h1>
|
||||
{category.description && <p>{category.description}</p>}
|
||||
<p>{category.count} posts</p>
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Tag Archive
|
||||
|
||||
Create a page that lists posts with a tag:
|
||||
|
||||
```astro title="src/pages/tag/[slug].astro"
|
||||
---
|
||||
import { getTaxonomyTerms, getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const tags = await getTaxonomyTerms("tag");
|
||||
|
||||
return tags.map((tag) => ({
|
||||
params: { slug: tag.slug },
|
||||
props: { tag },
|
||||
}));
|
||||
}
|
||||
|
||||
const { tag } = Astro.props;
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { tag: tag.slug },
|
||||
});
|
||||
---
|
||||
|
||||
<Base title={`Posts tagged "${tag.label}"`}>
|
||||
<h1>#{tag.label}</h1>
|
||||
|
||||
<ul>
|
||||
{posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Category List Widget
|
||||
|
||||
Display a list of categories with post counts:
|
||||
|
||||
```astro title="src/components/CategoryList.astro"
|
||||
---
|
||||
import { getTaxonomyTerms } from "emdash";
|
||||
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
---
|
||||
|
||||
<nav class="category-list">
|
||||
<h3>Categories</h3>
|
||||
<ul>
|
||||
{categories.map((cat) => (
|
||||
<li>
|
||||
<a href={`/category/${cat.slug}`}>
|
||||
{cat.label} ({cat.count})
|
||||
</a>
|
||||
{cat.children.length > 0 && (
|
||||
<ul>
|
||||
{cat.children.map((child) => (
|
||||
<li>
|
||||
<a href={`/category/${child.slug}`}>
|
||||
{child.label} ({child.count})
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Tag Cloud
|
||||
|
||||
Display tags with size based on usage:
|
||||
|
||||
```astro title="src/components/TagCloud.astro"
|
||||
---
|
||||
import { getTaxonomyTerms } from "emdash";
|
||||
|
||||
const tags = await getTaxonomyTerms("tag");
|
||||
|
||||
// Calculate font sizes based on count
|
||||
const counts = tags.map((t) => t.count ?? 0);
|
||||
const maxCount = Math.max(...counts, 1);
|
||||
const minSize = 0.8;
|
||||
const maxSize = 2;
|
||||
|
||||
function getSize(count: number) {
|
||||
const ratio = count / maxCount;
|
||||
return minSize + ratio * (maxSize - minSize);
|
||||
}
|
||||
---
|
||||
|
||||
<div class="tag-cloud">
|
||||
{tags.map((tag) => (
|
||||
<a
|
||||
href={`/tag/${tag.slug}`}
|
||||
style={`font-size: ${getSize(tag.count ?? 0)}rem`}
|
||||
>
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Displaying Terms on Content
|
||||
|
||||
Show categories and tags on a post:
|
||||
|
||||
```astro title="src/components/PostTerms.astro"
|
||||
---
|
||||
import { getEntryTerms } from "emdash";
|
||||
|
||||
interface Props {
|
||||
collection: string;
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
const { collection, entryId } = Astro.props;
|
||||
|
||||
const categories = await getEntryTerms(collection, entryId, "category");
|
||||
const tags = await getEntryTerms(collection, entryId, "tag");
|
||||
---
|
||||
|
||||
<div class="post-terms">
|
||||
{categories.length > 0 && (
|
||||
<div class="categories">
|
||||
<span>Posted in:</span>
|
||||
{categories.map((cat, i) => (
|
||||
<>
|
||||
{i > 0 && ", "}
|
||||
<a href={`/category/${cat.slug}`}>{cat.label}</a>
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div class="tags">
|
||||
{tags.map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="tag">
|
||||
#{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Custom Taxonomies
|
||||
|
||||
Create taxonomies beyond categories and tags for specialized needs.
|
||||
|
||||
### Create a Custom Taxonomy
|
||||
|
||||
Use the admin API to create a taxonomy:
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/taxonomies
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"name": "genre",
|
||||
"label": "Genres",
|
||||
"labelSingular": "Genre",
|
||||
"hierarchical": true,
|
||||
"collections": ["books", "movies"]
|
||||
}
|
||||
```
|
||||
|
||||
### Use Custom Taxonomies
|
||||
|
||||
Query and display custom taxonomies the same way as built-in ones:
|
||||
|
||||
```ts
|
||||
import { getTaxonomyTerms, getEmDashCollection } from "emdash";
|
||||
|
||||
// Get all genres
|
||||
const genres = await getTaxonomyTerms("genre");
|
||||
|
||||
// Get books in a genre
|
||||
const { entries: sciFiBooks } = await getEmDashCollection("books", {
|
||||
where: { genre: "science-fiction" },
|
||||
});
|
||||
```
|
||||
|
||||
### Assign to Collections
|
||||
|
||||
Taxonomies specify which collections they apply to:
|
||||
|
||||
```ts
|
||||
{
|
||||
"name": "difficulty",
|
||||
"label": "Difficulty Levels",
|
||||
"hierarchical": false,
|
||||
"collections": ["recipes", "tutorials"]
|
||||
}
|
||||
```
|
||||
|
||||
## Taxonomy API Reference
|
||||
|
||||
### REST Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
| --------------------------------------------- | ------ | ------------------------- |
|
||||
| `/_emdash/api/taxonomies` | GET | List taxonomy definitions |
|
||||
| `/_emdash/api/taxonomies` | POST | Create taxonomy |
|
||||
| `/_emdash/api/taxonomies/:name/terms` | GET | List terms |
|
||||
| `/_emdash/api/taxonomies/:name/terms` | POST | Create term |
|
||||
| `/_emdash/api/taxonomies/:name/terms/:slug` | GET | Get term |
|
||||
| `/_emdash/api/taxonomies/:name/terms/:slug` | PUT | Update term |
|
||||
| `/_emdash/api/taxonomies/:name/terms/:slug` | DELETE | Delete term |
|
||||
|
||||
### Assign Terms to Content
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/content/posts/post-123/terms/category
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"termIds": ["term_news", "term_featured"]
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Create a Blog](/guides/create-a-blog/) - Use categories and tags in a blog
|
||||
- [Querying Content](/guides/querying-content/) - Filter by taxonomy terms
|
||||
- [Working with Content](/guides/working-with-content/) - Assign terms in the editor
|
||||
363
docs/src/content/docs/guides/widgets.mdx
Normal file
@@ -0,0 +1,363 @@
|
||||
---
|
||||
title: Widget Areas
|
||||
description: Add dynamic content blocks to sidebars, footers, and other template regions.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Widget areas are named regions in your templates where administrators can place content blocks. Use them for sidebars, footer columns, promotional banners, or any section that editors should control without touching code.
|
||||
|
||||
## Querying Widget Areas
|
||||
|
||||
Use `getWidgetArea()` to fetch a widget area by name:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getWidgetArea } from "emdash";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
---
|
||||
|
||||
{sidebar && sidebar.widgets.length > 0 && (
|
||||
<aside class="sidebar">
|
||||
{sidebar.widgets.map(widget => (
|
||||
<div class="widget">
|
||||
{widget.title && <h3>{widget.title}</h3>}
|
||||
<!-- Render widget content -->
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
```
|
||||
|
||||
The function returns `null` if the widget area does not exist.
|
||||
|
||||
## Widget Area Structure
|
||||
|
||||
A widget area contains metadata and an array of widgets:
|
||||
|
||||
```ts
|
||||
interface WidgetArea {
|
||||
id: string;
|
||||
name: string; // Unique identifier ("sidebar", "footer-1")
|
||||
label: string; // Display name ("Main Sidebar")
|
||||
description?: string;
|
||||
widgets: Widget[];
|
||||
}
|
||||
|
||||
interface Widget {
|
||||
id: string;
|
||||
type: "content" | "menu" | "component";
|
||||
title?: string;
|
||||
// Type-specific fields
|
||||
content?: PortableTextBlock[]; // For content widgets
|
||||
menuName?: string; // For menu widgets
|
||||
componentId?: string; // For component widgets
|
||||
componentProps?: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
## Widget Types
|
||||
|
||||
EmDash supports three widget types:
|
||||
|
||||
### Content Widgets
|
||||
|
||||
Rich text content stored as Portable Text. Render using the `PortableText` component:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
---
|
||||
|
||||
{widget.type === "content" && widget.content && (
|
||||
<div class="widget-content">
|
||||
<PortableText value={widget.content} />
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
### Menu Widgets
|
||||
|
||||
Display a navigation menu within a widget area:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
const menu = widget.menuName ? await getMenu(widget.menuName) : null;
|
||||
---
|
||||
|
||||
{widget.type === "menu" && menu && (
|
||||
<nav class="widget-nav">
|
||||
<ul>
|
||||
{menu.items.map(item => (
|
||||
<li><a href={item.url}>{item.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
```
|
||||
|
||||
### Component Widgets
|
||||
|
||||
Render a registered component with configurable props. EmDash includes these core components:
|
||||
|
||||
| Component ID | Description | Props |
|
||||
| ------------------- | ----------------------- | ------------------------------------- |
|
||||
| `core:recent-posts` | List of recent posts | `count`, `showThumbnails`, `showDate` |
|
||||
| `core:categories` | Category list | `showCount`, `hierarchical` |
|
||||
| `core:tags` | Tag cloud | `showCount`, `limit` |
|
||||
| `core:search` | Search form | `placeholder` |
|
||||
| `core:archives` | Monthly/yearly archives | `type`, `limit` |
|
||||
|
||||
## Rendering Widgets
|
||||
|
||||
Create a reusable widget renderer component:
|
||||
|
||||
```astro title="src/components/WidgetRenderer.astro"
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
import { getMenu } from "emdash";
|
||||
import type { Widget } from "emdash";
|
||||
|
||||
// Import your widget components
|
||||
import RecentPosts from "./widgets/RecentPosts.astro";
|
||||
import Categories from "./widgets/Categories.astro";
|
||||
import TagCloud from "./widgets/TagCloud.astro";
|
||||
import SearchForm from "./widgets/SearchForm.astro";
|
||||
import Archives from "./widgets/Archives.astro";
|
||||
|
||||
interface Props {
|
||||
widget: Widget;
|
||||
}
|
||||
|
||||
const { widget } = Astro.props;
|
||||
|
||||
const componentMap: Record<string, any> = {
|
||||
"core:recent-posts": RecentPosts,
|
||||
"core:categories": Categories,
|
||||
"core:tags": TagCloud,
|
||||
"core:search": SearchForm,
|
||||
"core:archives": Archives,
|
||||
};
|
||||
|
||||
const menu = widget.type === "menu" && widget.menuName
|
||||
? await getMenu(widget.menuName)
|
||||
: null;
|
||||
---
|
||||
|
||||
<div class="widget">
|
||||
{widget.title && <h3 class="widget-title">{widget.title}</h3>}
|
||||
|
||||
{widget.type === "content" && widget.content && (
|
||||
<div class="widget-content">
|
||||
<PortableText value={widget.content} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget.type === "menu" && menu && (
|
||||
<nav class="widget-menu">
|
||||
<ul>
|
||||
{menu.items.map(item => (
|
||||
<li><a href={item.url}>{item.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{widget.type === "component" && widget.componentId && componentMap[widget.componentId] && (
|
||||
<Fragment>
|
||||
{(() => {
|
||||
const Component = componentMap[widget.componentId!];
|
||||
return <Component {...widget.componentProps} />;
|
||||
})()}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
```
|
||||
|
||||
## Example Widget Components
|
||||
|
||||
### Recent Posts Widget
|
||||
|
||||
```astro title="src/components/widgets/RecentPosts.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
interface Props {
|
||||
count?: number;
|
||||
showThumbnails?: boolean;
|
||||
showDate?: boolean;
|
||||
}
|
||||
|
||||
const { count = 5, showThumbnails = false, showDate = true } = Astro.props;
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
limit: count,
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
---
|
||||
|
||||
<ul class="recent-posts">
|
||||
{posts.map(post => (
|
||||
<li>
|
||||
{showThumbnails && post.data.featured_image && (
|
||||
<img src={post.data.featured_image} alt="" class="thumbnail" />
|
||||
)}
|
||||
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
|
||||
{showDate && post.data.publishedAt && (
|
||||
<time datetime={post.data.publishedAt.toISOString()}>
|
||||
{post.data.publishedAt.toLocaleDateString()}
|
||||
</time>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
```
|
||||
|
||||
### Search Widget
|
||||
|
||||
```astro title="src/components/widgets/SearchForm.astro"
|
||||
---
|
||||
interface Props {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const { placeholder = "Search..." } = Astro.props;
|
||||
---
|
||||
|
||||
<form action="/search" method="get" class="search-form">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
placeholder={placeholder}
|
||||
aria-label="Search"
|
||||
/>
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
```
|
||||
|
||||
## Using Widget Areas in Layouts
|
||||
|
||||
The following example shows a blog layout with a sidebar widget area:
|
||||
|
||||
```astro title="src/layouts/BlogPost.astro"
|
||||
---
|
||||
import { getWidgetArea } from "emdash";
|
||||
import WidgetRenderer from "../components/WidgetRenderer.astro";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
---
|
||||
|
||||
<div class="layout">
|
||||
<main class="content">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
{sidebar && sidebar.widgets.length > 0 && (
|
||||
<aside class="sidebar">
|
||||
{sidebar.widgets.map(widget => (
|
||||
<WidgetRenderer widget={widget} />
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 300px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
## Listing All Widget Areas
|
||||
|
||||
Use `getWidgetAreas()` to retrieve all widget areas with their widgets:
|
||||
|
||||
```ts
|
||||
import { getWidgetAreas } from "emdash";
|
||||
|
||||
const areas = await getWidgetAreas();
|
||||
// Returns all areas with widgets populated
|
||||
```
|
||||
|
||||
## Creating Widget Areas
|
||||
|
||||
Create widget areas through the admin interface at `/_emdash/admin/widgets`, or use the admin API:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/widget-areas
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"name": "footer-1",
|
||||
"label": "Footer Column 1",
|
||||
"description": "First column in the footer"
|
||||
}
|
||||
```
|
||||
|
||||
Add a content widget:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/widget-areas/footer-1/widgets
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About Us",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Welcome to our site." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Add a component widget:
|
||||
|
||||
```http
|
||||
POST /_emdash/api/widget-areas/sidebar/widgets
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "component",
|
||||
"title": "Recent Posts",
|
||||
"componentId": "core:recent-posts",
|
||||
"componentProps": { "count": 5, "showDate": true }
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getWidgetArea(name)`
|
||||
|
||||
Fetch a widget area by name with all widgets.
|
||||
|
||||
**Parameters:**
|
||||
|
||||
- `name` — The widget area's unique identifier (string)
|
||||
|
||||
**Returns:** `Promise<WidgetArea | null>`
|
||||
|
||||
### `getWidgetAreas()`
|
||||
|
||||
List all widget areas with their widgets.
|
||||
|
||||
**Returns:** `Promise<WidgetArea[]>`
|
||||
|
||||
### `getWidgetComponents()`
|
||||
|
||||
List available widget component definitions for the admin UI.
|
||||
|
||||
**Returns:** `WidgetComponentDef[]`
|
||||
283
docs/src/content/docs/guides/working-with-content.mdx
Normal file
@@ -0,0 +1,283 @@
|
||||
---
|
||||
title: Working with Content
|
||||
description: Create, edit, and manage content in the EmDash admin dashboard.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
This guide covers how to create, edit, and manage content using the EmDash admin dashboard.
|
||||
|
||||
## Accessing the Admin
|
||||
|
||||
Open your browser to `/_emdash/admin` on your site. Log in with the credentials you created during setup.
|
||||
|
||||
The dashboard displays:
|
||||
|
||||
- **Sidebar** - Navigation to collections, media, and settings
|
||||
- **Content list** - Entries in the selected collection
|
||||
- **Quick actions** - Create new content, bulk operations
|
||||
|
||||
## Creating Content
|
||||
|
||||
1. Click a collection name in the sidebar (e.g., **Posts**)
|
||||
|
||||
2. Click **New Post** (or the equivalent for your collection)
|
||||
|
||||
3. Fill in the required fields:
|
||||
- **Title** - The content's display name
|
||||
- **Slug** - URL identifier (auto-generated from title, editable)
|
||||
|
||||
4. Add content using the rich text editor
|
||||
|
||||
5. Set metadata in the sidebar:
|
||||
- **Status** - Draft, Published, or Archived
|
||||
- **Publication date** - When to publish
|
||||
- **Categories and tags** - Taxonomy assignments
|
||||
|
||||
6. Click **Save**
|
||||
|
||||
<Aside>
|
||||
Drafts are only visible in the admin. Change status to **Published** to make content visible on
|
||||
your site.
|
||||
</Aside>
|
||||
|
||||
## Content Statuses
|
||||
|
||||
Every entry has one of three statuses:
|
||||
|
||||
| Status | Visibility | Use case |
|
||||
| ------------- | ---------- | ---------------- |
|
||||
| **Draft** | Admin only | Work in progress |
|
||||
| **Published** | Public | Live content |
|
||||
| **Archived** | Admin only | Retired content |
|
||||
|
||||
Change status using the dropdown in the editor sidebar.
|
||||
|
||||
## The Rich Text Editor
|
||||
|
||||
EmDash's editor supports:
|
||||
|
||||
- **Headings** - H2 through H6
|
||||
- **Formatting** - Bold, italic, underline, strikethrough
|
||||
- **Lists** - Ordered and unordered
|
||||
- **Links** - Internal and external
|
||||
- **Images** - Insert from media library
|
||||
- **Code blocks** - With syntax highlighting
|
||||
- **Embeds** - YouTube, Vimeo, Twitter
|
||||
- **Sections** - Reusable content blocks via `/section` command
|
||||
|
||||
### Slash Commands
|
||||
|
||||
Type `/` to access quick insert commands:
|
||||
|
||||
| Command | Action |
|
||||
| ---------------------------- | ----------------------------------- |
|
||||
| `/section` | Insert a reusable section |
|
||||
| `/image` | Insert an image from media library |
|
||||
| `/code` | Insert a code block |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
| ------ | ---------------------- |
|
||||
| Bold | `Ctrl/Cmd + B` |
|
||||
| Italic | `Ctrl/Cmd + I` |
|
||||
| Link | `Ctrl/Cmd + K` |
|
||||
| Undo | `Ctrl/Cmd + Z` |
|
||||
| Redo | `Ctrl/Cmd + Shift + Z` |
|
||||
| Save | `Ctrl/Cmd + S` |
|
||||
|
||||
### Inserting Images
|
||||
|
||||
1. Click the image button in the toolbar
|
||||
|
||||
2. Select an existing image from the media library, or upload a new one
|
||||
|
||||
3. Add alt text (required for accessibility)
|
||||
|
||||
4. Adjust alignment and size options
|
||||
|
||||
5. Click **Insert**
|
||||
|
||||
## Editing Content
|
||||
|
||||
1. Navigate to the collection containing the content
|
||||
|
||||
2. Click on the entry you want to edit
|
||||
|
||||
3. Make your changes
|
||||
|
||||
4. Click **Save**
|
||||
|
||||
Changes to published content appear immediately on your site. No rebuild required.
|
||||
|
||||
### Revision History
|
||||
|
||||
EmDash tracks changes to content. Access revision history from the editor sidebar:
|
||||
|
||||
1. Click **Revisions** in the editor sidebar
|
||||
|
||||
2. View the list of previous versions with timestamps
|
||||
|
||||
3. Click a revision to preview it
|
||||
|
||||
4. Click **Restore** to revert to that version
|
||||
|
||||
<Aside type="caution">
|
||||
Restoring a revision creates a new revision with the restored content. The original revision
|
||||
history is preserved.
|
||||
</Aside>
|
||||
|
||||
## Bulk Operations
|
||||
|
||||
Perform actions on multiple entries at once:
|
||||
|
||||
1. Use the checkboxes to select entries in the content list
|
||||
|
||||
2. Click the **Bulk Actions** dropdown
|
||||
|
||||
3. Select an action:
|
||||
- **Publish** - Set all selected to published
|
||||
- **Archive** - Set all selected to archived
|
||||
- **Delete** - Permanently remove selected
|
||||
|
||||
4. Confirm the action
|
||||
|
||||
## Searching and Filtering
|
||||
|
||||
### Search
|
||||
|
||||
Use the search box to find content by title or content. Search is case-insensitive and matches partial words.
|
||||
|
||||
### Filters
|
||||
|
||||
Filter the content list by:
|
||||
|
||||
- **Status** - Draft, Published, Archived
|
||||
- **Date range** - Created or modified dates
|
||||
- **Author** - Who created the content
|
||||
- **Taxonomy** - Category or tag assignments
|
||||
|
||||
Click **Clear Filters** to reset.
|
||||
|
||||
## Scheduling Content
|
||||
|
||||
Schedule content to publish at a future date:
|
||||
|
||||
1. Create or edit content
|
||||
|
||||
2. Set status to **Draft**
|
||||
|
||||
3. Set the **Publication date** to a future date and time
|
||||
|
||||
4. Click **Save**
|
||||
|
||||
When the publication date arrives, the content automatically becomes published.
|
||||
|
||||
<Aside type="tip">
|
||||
Scheduled publishing requires your site to be server-rendered or have a scheduled task that checks
|
||||
for pending publications.
|
||||
</Aside>
|
||||
|
||||
## Deleting Content
|
||||
|
||||
Delete content from the edit screen or content list:
|
||||
|
||||
### From the Editor
|
||||
|
||||
1. Open the content you want to delete
|
||||
|
||||
2. Click **Delete** in the toolbar
|
||||
|
||||
3. Confirm the deletion
|
||||
|
||||
### From the List
|
||||
|
||||
1. Select entries using checkboxes
|
||||
|
||||
2. Click **Bulk Actions** > **Delete**
|
||||
|
||||
3. Confirm the deletion
|
||||
|
||||
<Aside type="caution">
|
||||
Deleted content is permanently removed and cannot be recovered. Consider archiving instead if you
|
||||
might need the content later.
|
||||
</Aside>
|
||||
|
||||
## Content API
|
||||
|
||||
For programmatic access, use the EmDash admin API:
|
||||
|
||||
### Create Content
|
||||
|
||||
```bash
|
||||
POST /_emdash/api/content/posts
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"title": "My New Post",
|
||||
"slug": "my-new-post",
|
||||
"content": "<p>Post content here</p>",
|
||||
"status": "draft"
|
||||
}
|
||||
```
|
||||
|
||||
### Update Content
|
||||
|
||||
```bash
|
||||
PUT /_emdash/api/content/posts/my-new-post
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
|
||||
{
|
||||
"title": "Updated Title",
|
||||
"status": "published"
|
||||
}
|
||||
```
|
||||
|
||||
### Delete Content
|
||||
|
||||
```bash
|
||||
DELETE /_emdash/api/content/posts/my-new-post
|
||||
Authorization: Bearer YOUR_API_TOKEN
|
||||
```
|
||||
|
||||
## Translating Content
|
||||
|
||||
When [i18n is enabled](/guides/internationalization/), you can create translations of any content entry.
|
||||
|
||||
### Creating a translation
|
||||
|
||||
1. Open the content entry you want to translate
|
||||
|
||||
2. In the editor sidebar, find the **Translations** panel
|
||||
|
||||
3. Click **Translate** next to the target locale
|
||||
|
||||
4. Edit the pre-filled content — adjust the title, slug, and body for the new language
|
||||
|
||||
5. Click **Save**
|
||||
|
||||
The new translation is linked to the original entry and starts as a draft. Publish it independently when the translation is ready.
|
||||
|
||||
### Switching between translations
|
||||
|
||||
The Translations panel shows all configured locales. Click **Edit** next to any existing translation to navigate to it directly. The current locale is marked with a checkmark.
|
||||
|
||||
### Locale filter
|
||||
|
||||
In the content list, use the locale dropdown in the toolbar to filter entries by language. Each entry shows its locale in a dedicated column.
|
||||
|
||||
<Aside type="tip">
|
||||
Each translation has its own slug, status, and revision history. Publish, schedule, and manage translations independently.
|
||||
</Aside>
|
||||
|
||||
See the [Internationalization guide](/guides/internationalization/) for full details on configuration, querying, and the language switcher.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Querying Content](/guides/querying-content/) - Retrieve content in your templates
|
||||
- [Media Library](/guides/media-library/) - Upload and manage files
|
||||
- [Taxonomies](/guides/taxonomies/) - Organize content with categories and tags
|
||||
- [Internationalization](/guides/internationalization/) - Multilingual content and translations
|
||||
253
docs/src/content/docs/guides/x402-payments.mdx
Normal file
@@ -0,0 +1,253 @@
|
||||
---
|
||||
title: x402 Payments
|
||||
description: Monetize content with the x402 payment protocol — charge bots, not humans.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
The `@emdash-cms/x402` package adds [x402 payment protocol](https://www.x402.org/) support to any Astro site on Cloudflare. It works standalone — no dependency on EmDash core — but pairs well with EmDash's CMS fields for per-page pricing.
|
||||
|
||||
x402 is an HTTP-native payment protocol. When a client requests a paid resource without payment, the server responds with `402 Payment Required` and machine-readable payment instructions. Agents and browsers that understand x402 can complete payment automatically and retry the request.
|
||||
|
||||
## When to Use This
|
||||
|
||||
The most common use case is **bot-only mode**: charge AI agents and scrapers for content access while letting human visitors read for free. This uses Cloudflare Bot Management to distinguish bots from humans.
|
||||
|
||||
You can also enforce payment for all visitors, or check for payment headers without enforcing (conditional rendering).
|
||||
|
||||
## Installation
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="pnpm">
|
||||
```bash
|
||||
pnpm add @emdash-cms/x402
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="npm">
|
||||
```bash
|
||||
npm install @emdash-cms/x402
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="yarn">
|
||||
```bash
|
||||
yarn add @emdash-cms/x402
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Setup
|
||||
|
||||
Add the integration to your Astro config:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import { x402 } from "@emdash-cms/x402";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
x402({
|
||||
payTo: "0xYourWalletAddress",
|
||||
network: "eip155:8453", // Base mainnet
|
||||
defaultPrice: "$0.01",
|
||||
botOnly: true,
|
||||
botScoreThreshold: 30,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Add the type reference so TypeScript knows about `Astro.locals.x402`:
|
||||
|
||||
```ts title="src/env.d.ts"
|
||||
/// <reference types="@emdash-cms/x402/locals" />
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
The integration puts an enforcer on `Astro.locals.x402`. Call `enforce()` in your page frontmatter to gate content behind payment:
|
||||
|
||||
```astro title="src/pages/posts/[...slug].astro"
|
||||
---
|
||||
const { x402 } = Astro.locals;
|
||||
|
||||
const result = await x402.enforce(Astro.request, {
|
||||
price: "$0.05",
|
||||
description: "Premium article",
|
||||
});
|
||||
|
||||
// If the request has no valid payment, enforce() returns a 402 Response.
|
||||
// Return it directly to send payment instructions to the client.
|
||||
if (result instanceof Response) return result;
|
||||
|
||||
// Payment verified (or skipped in botOnly mode). Apply response headers
|
||||
// so the client gets settlement proof.
|
||||
x402.applyHeaders(result, Astro.response);
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>Premium content</h1>
|
||||
</article>
|
||||
```
|
||||
|
||||
The `enforce()` method returns either:
|
||||
|
||||
- A **`Response`** (402) — the client needs to pay. Return it directly.
|
||||
- An **`EnforceResult`** — the request should proceed. The content was paid for, or enforcement was skipped (human in botOnly mode).
|
||||
|
||||
## Bot-Only Mode
|
||||
|
||||
When `botOnly` is `true`, the integration reads `request.cf.botManagement.score` to classify requests:
|
||||
|
||||
- **Score below threshold** (default 30) -> treated as bot, payment enforced
|
||||
- **Score at or above threshold** -> treated as human, enforcement skipped
|
||||
- **No bot management data** (local dev, non-CF deployment) -> treated as human
|
||||
|
||||
The `EnforceResult` includes a `skipped` flag so you can distinguish "didn't need to pay" from "paid":
|
||||
|
||||
```astro
|
||||
---
|
||||
const result = await x402.enforce(Astro.request, { price: "$0.01" });
|
||||
if (result instanceof Response) return result;
|
||||
|
||||
x402.applyHeaders(result, Astro.response);
|
||||
|
||||
// result.paid — true if payment was verified
|
||||
// result.skipped — true if enforcement was skipped (human in botOnly mode)
|
||||
// result.payer — wallet address of payer (if paid)
|
||||
---
|
||||
```
|
||||
|
||||
<Aside>
|
||||
Bot-only mode requires a Cloudflare deployment with Bot Management enabled. In local development,
|
||||
all requests are treated as human and enforcement is skipped.
|
||||
</Aside>
|
||||
|
||||
## Per-Page Pricing with EmDash
|
||||
|
||||
When using EmDash, you can add a `number` field to your collection for per-page pricing. No special schema or admin UI is needed — just a regular CMS field:
|
||||
|
||||
```astro title="src/pages/posts/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry } = await getEmDashEntry("posts", slug);
|
||||
|
||||
if (!entry) return Astro.redirect("/404");
|
||||
|
||||
const { x402 } = Astro.locals;
|
||||
|
||||
// Use the price from the CMS, falling back to a default
|
||||
const result = await x402.enforce(Astro.request, {
|
||||
price: entry.data.price || "$0.01",
|
||||
description: entry.data.title,
|
||||
});
|
||||
if (result instanceof Response) return result;
|
||||
|
||||
x402.applyHeaders(result, Astro.response);
|
||||
---
|
||||
|
||||
<article>
|
||||
<h1>{entry.data.title}</h1>
|
||||
</article>
|
||||
```
|
||||
|
||||
## Checking for Payment Without Enforcing
|
||||
|
||||
Use `hasPayment()` to check if a request includes payment headers without verifying or enforcing. This is useful for conditional rendering — showing different content to paying vs non-paying visitors:
|
||||
|
||||
```astro
|
||||
---
|
||||
const { x402 } = Astro.locals;
|
||||
|
||||
const hasPaid = x402.hasPayment(Astro.request);
|
||||
---
|
||||
|
||||
{hasPaid ? (
|
||||
<p>Full premium content here.</p>
|
||||
) : (
|
||||
<p>Subscribe for the full article.</p>
|
||||
)}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
`hasPayment()` only checks for the presence of a payment header. It does not verify the payment
|
||||
is valid. Use `enforce()` when you need verified payment.
|
||||
</Aside>
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------- | --------- | -------------------------------- | ------------------------------------------------ |
|
||||
| `payTo` | `string` | required | Destination wallet address |
|
||||
| `network` | `string` | required | CAIP-2 network identifier (e.g., `eip155:8453`) |
|
||||
| `defaultPrice` | `Price` | — | Default price, overridable per-page |
|
||||
| `facilitatorUrl` | `string` | `https://x402.org/facilitator` | Payment facilitator URL |
|
||||
| `scheme` | `string` | `"exact"` | Payment scheme |
|
||||
| `maxTimeoutSeconds` | `number` | `60` | Maximum timeout for payment signatures |
|
||||
| `evm` | `boolean` | `true` | Enable EVM chain support |
|
||||
| `svm` | `boolean` | `false` | Enable Solana chain support (requires `@x402/svm`) |
|
||||
| `botOnly` | `boolean` | `false` | Only enforce payment for bots |
|
||||
| `botScoreThreshold` | `number` | `30` | Bot score threshold (1-99, lower = more likely bot) |
|
||||
|
||||
### Price Format
|
||||
|
||||
Prices can be specified in several formats:
|
||||
|
||||
- **Dollar string** — `"$0.10"` (the `$` prefix is stripped, value passed as-is)
|
||||
- **Numeric string** — `"0.10"`
|
||||
- **Number** — `0.10`
|
||||
- **Object** — `{ amount: "100000", asset: "0x...", extra: {} }` for explicit asset/amount
|
||||
|
||||
### Network Identifiers
|
||||
|
||||
Networks use [CAIP-2](https://github.com/ChainAgnostic/CAIPs/blob/main/CAIPs/caip-2.md) format:
|
||||
|
||||
| Network | Identifier |
|
||||
| ------------- | ---------------- |
|
||||
| Base mainnet | `eip155:8453` |
|
||||
| Base Sepolia | `eip155:84532` |
|
||||
| Ethereum | `eip155:1` |
|
||||
| Solana | `solana:mainnet` |
|
||||
|
||||
## Enforce Options
|
||||
|
||||
Override config defaults for a specific page:
|
||||
|
||||
```ts
|
||||
await x402.enforce(Astro.request, {
|
||||
price: "$0.25", // Override price
|
||||
payTo: "0xDifferentWallet", // Override wallet
|
||||
network: "eip155:1", // Override network
|
||||
description: "Article: How x402 Works", // Resource description
|
||||
mimeType: "text/html", // MIME type hint
|
||||
});
|
||||
```
|
||||
|
||||
## Solana Support
|
||||
|
||||
Solana is opt-in. Install `@x402/svm` and enable it in config:
|
||||
|
||||
```bash
|
||||
pnpm add @x402/svm
|
||||
```
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
x402({
|
||||
payTo: "YourSolanaAddress",
|
||||
network: "solana:mainnet",
|
||||
svm: true,
|
||||
evm: false, // Disable EVM if only using Solana
|
||||
});
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The `x402()` integration registers middleware that creates an enforcer and places it on `Astro.locals.x402`
|
||||
2. Configuration is passed to the middleware via a Vite virtual module (`virtual:x402/config`)
|
||||
3. When `enforce()` is called, it checks for a `payment-signature` header on the request
|
||||
4. If no payment header is present, a `402 Payment Required` response is returned with payment instructions in the `PAYMENT-REQUIRED` header
|
||||
5. If a payment header is present, it's verified through the facilitator service and settled
|
||||
6. After settlement, `PAYMENT-RESPONSE` headers are set on the response via `applyHeaders()`
|
||||
|
||||
The resource server is initialized lazily on first request and cached for the worker lifetime.
|
||||
57
docs/src/content/docs/index.mdx
Normal file
@@ -0,0 +1,57 @@
|
||||
---
|
||||
title: EmDash
|
||||
description: The Astro-native CMS. A modern successor to WordPress with type-safe content, plugin extensibility, and portable deployment.
|
||||
template: splash
|
||||
hero:
|
||||
tagline: A modern, Astro-native CMS. Type-safe content, plugin extensibility, and portable deployment.
|
||||
actions:
|
||||
- text: Get Started
|
||||
link: /getting-started/
|
||||
icon: right-arrow
|
||||
- text: View on GitHub
|
||||
link: https://github.com/emdash-cms/emdash
|
||||
icon: external
|
||||
variant: minimal
|
||||
---
|
||||
|
||||
import { Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
## Why EmDash?
|
||||
|
||||
<CardGrid stagger>
|
||||
<Card title="Astro-Native" icon="astro">
|
||||
Built on Astro 6's Live Content Collections. No rebuilds needed—content updates instantly at
|
||||
runtime.
|
||||
</Card>
|
||||
<Card title="Type-Safe" icon="setting">
|
||||
Schema defined in the database, TypeScript types generated automatically. Full type safety from
|
||||
database to template.
|
||||
</Card>
|
||||
<Card title="Plugin Ecosystem" icon="puzzle">
|
||||
WordPress-inspired plugin system with hooks, storage, and admin UI extensions. Agent-portable
|
||||
plugin migration from WordPress.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create a new EmDash site
|
||||
npm create astro@latest -- --template @emdash-cms/template-blog
|
||||
|
||||
# Start the dev server
|
||||
npm run dev
|
||||
|
||||
# Visit the admin at http://localhost:4321/_emdash/admin
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **Visual Schema Builder** — Create collections and fields from the admin panel
|
||||
- **Rich Text Editor** — TipTap-powered editing with Portable Text storage
|
||||
- **Media Library** — Drag-and-drop uploads with signed URL support
|
||||
- **Navigation Menus** — Admin-editable menus with nested items
|
||||
- **Taxonomies** — Categories, tags, and custom classification systems
|
||||
- **Widget Areas** — Configurable content regions for sidebars and footers
|
||||
- **WordPress Import** — Migrate content from WXR exports or REST API
|
||||
- **Preview System** — Token-based preview for draft content
|
||||
101
docs/src/content/docs/introduction.mdx
Normal file
@@ -0,0 +1,101 @@
|
||||
---
|
||||
title: Introduction to EmDash
|
||||
description: Learn what EmDash is, how it works, and whether it's right for your project.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is an **Astro-native content management system**. It brings familiar CMS patterns—collections, taxonomies, menus, widgets, and a polished admin UI—directly into your Astro site with full TypeScript support and portable deployment.
|
||||
|
||||
## What EmDash Is
|
||||
|
||||
EmDash is a CMS built specifically for [Astro](https://astro.build). It uses Astro 6's Live Content Collections to serve content at runtime without rebuilds. Content is stored in SQLite-compatible databases (D1, libSQL, local SQLite) and media in S3-compatible storage (R2, local filesystem).
|
||||
|
||||
**Key characteristics:**
|
||||
|
||||
- **Database-first schema** — Collections and fields are defined in the database, not code. Create and modify content types from the admin UI.
|
||||
- **Live Collections** — Content changes are immediately available. No static rebuilds needed.
|
||||
- **Plugin system** — WordPress-inspired hooks, storage, settings, and admin UI extensions.
|
||||
- **Cloud-portable** — Runs on Cloudflare (Workers + D1 + R2), Node.js, local SQLite, and any S3-compatible storage.
|
||||
|
||||
## What EmDash Is Not
|
||||
|
||||
- **Not a headless CMS** — EmDash is tightly integrated with Astro. It's not a separate service you call via API.
|
||||
- **Not WordPress-compatible** — No PHP, no WordPress plugins running directly. But WordPress content and concepts migrate cleanly.
|
||||
- **Not a page builder** — EmDash focuses on structured content. For visual page building, use Astro components.
|
||||
|
||||
## Who EmDash Is For
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Agency developers" icon="laptop">
|
||||
Spin up client sites quickly with reusable plugins and themes. No PHP security updates, no
|
||||
plugin conflicts.
|
||||
</Card>
|
||||
<Card title="Solo developers" icon="seti:todo">
|
||||
Full-stack framework with CMS built in. No separate headless CMS to manage.
|
||||
</Card>
|
||||
<Card title="Content editors" icon="pencil">
|
||||
Intuitive admin panel. Create and edit content without touching code.
|
||||
</Card>
|
||||
<Card title="WordPress users" icon="right-arrow">
|
||||
Migration path for content and plugins. Modern tooling, familiar concepts.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Your Astro Site │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ EmDash Integration │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────────┐ ┌─────────────┐ ┌──────────────┐ │ │
|
||||
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
|
||||
│ │ │ Engine │ │ Panel │ │ │ │ │
|
||||
│ │ └─────────────┘ └─────────────┘ └──────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────────────────────────────────────────────────┐│ │
|
||||
│ │ │ Data Layer ││ │
|
||||
│ │ │ SQLite/D1 ←→ Kysely ←→ R2/S3/Local ││ │
|
||||
│ │ └───────────────────────────────────────────────────┘│ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ Astro Framework │ │
|
||||
│ │ Live Collections • Sessions • Middleware │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
EmDash is Astro-native and cloud-portable. It runs on Cloudflare (D1 + R2 + Workers), Node.js,
|
||||
local SQLite, and any S3-compatible storage.
|
||||
</Aside>
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Before diving in, familiarize yourself with these key concepts:
|
||||
|
||||
- **Collections** — Content types defined in the database (posts, pages, products, etc.)
|
||||
- **Fields** — The properties of a collection (title, content, price, etc.)
|
||||
- **Taxonomies** — Classification systems (categories, tags, custom taxonomies)
|
||||
- **Menus** — Admin-editable navigation structures
|
||||
- **Widget Areas** — Configurable content regions for sidebars and footers
|
||||
- **Plugins** — Extensions that add functionality via hooks, storage, and UI
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Get Started" icon="rocket">
|
||||
[Create your first EmDash site](/getting-started/) in under 5 minutes.
|
||||
</Card>
|
||||
<Card title="Explore Concepts" icon="open-book">
|
||||
Learn about [architecture](/concepts/architecture/) and the [content
|
||||
model](/concepts/content-model/).
|
||||
</Card>
|
||||
<Card title="Migrate from WordPress" icon="right-arrow">
|
||||
[Import your WordPress content](/migration/from-wordpress/) and understand the concept mapping.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
427
docs/src/content/docs/migration/content-import.mdx
Normal file
@@ -0,0 +1,427 @@
|
||||
---
|
||||
title: Content Import
|
||||
description: Import content from WordPress and other sources into EmDash.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's import system uses a pluggable source architecture. Each source knows how to probe, analyze, and fetch content from a specific platform.
|
||||
|
||||
## Import Sources
|
||||
|
||||
| Source ID | Platform | Probe | OAuth | Full Import |
|
||||
| ---------------- | --------------------- | ----- | ----- | ----------- |
|
||||
| `wxr` | WordPress export file | No | No | Yes |
|
||||
| `wordpress-com` | WordPress.com | Yes | Yes | Yes |
|
||||
| `wordpress-rest` | Self-hosted WordPress | Yes | No | Probe only |
|
||||
|
||||
### WXR File Upload
|
||||
|
||||
The most complete import method. Upload a WordPress eXtended RSS (WXR) export file directly to the admin dashboard.
|
||||
|
||||
**Capabilities:**
|
||||
|
||||
- All post types (including custom)
|
||||
- All meta fields
|
||||
- Drafts and private posts
|
||||
- Full taxonomy hierarchy
|
||||
- Media attachment metadata
|
||||
|
||||
**How to get a WXR file:**
|
||||
|
||||
<Steps>
|
||||
|
||||
1. In WordPress admin, go to **Tools → Export**
|
||||
2. Select **All content** or specific post types
|
||||
3. Click **Download Export File**
|
||||
4. Upload the `.xml` file to EmDash
|
||||
|
||||
</Steps>
|
||||
|
||||
### WordPress.com OAuth
|
||||
|
||||
For sites hosted on WordPress.com, connect via OAuth to import without manual file exports.
|
||||
|
||||
<Steps>
|
||||
|
||||
1. Enter your WordPress.com site URL
|
||||
2. Click **Connect with WordPress.com**
|
||||
3. Authorize EmDash in the WordPress.com popup
|
||||
4. Select content to import
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="caution">
|
||||
WordPress.com OAuth requires environment variables `WPCOM_CLIENT_ID` and `WPCOM_CLIENT_SECRET`.
|
||||
Register an app at [developer.wordpress.com](https://developer.wordpress.com/apps/).
|
||||
</Aside>
|
||||
|
||||
**What's included:**
|
||||
|
||||
- Published and draft content
|
||||
- Private posts (with authorization)
|
||||
- Media files via API
|
||||
- Custom fields exposed to REST API
|
||||
|
||||
### WordPress REST API Probe
|
||||
|
||||
When you enter a URL, EmDash probes the site to detect WordPress and show available content:
|
||||
|
||||
```
|
||||
Detected: WordPress 6.4
|
||||
├── Posts: 127 (published)
|
||||
├── Pages: 12 (published)
|
||||
└── Media: 89 files
|
||||
|
||||
Note: Drafts and private content require authentication
|
||||
or a full WXR export.
|
||||
```
|
||||
|
||||
The REST probe is informational. For complete imports, it suggests uploading a WXR file or connecting via OAuth (for WordPress.com).
|
||||
|
||||
## Import Flow
|
||||
|
||||
All sources follow the same flow:
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ Connect │────▶│ Analyze │────▶│ Prepare │────▶│ Execute │
|
||||
│ (probe/ │ │ (schema │ │ (create │ │ (import │
|
||||
│ upload) │ │ check) │ │ schema) │ │ content) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### Step 1: Connect
|
||||
|
||||
Enter a URL to probe or upload a file directly.
|
||||
|
||||
**URL probing** runs all registered sources in parallel. The highest-confidence match determines the suggested next action:
|
||||
|
||||
- **WordPress.com site** → Offer OAuth connection
|
||||
- **Self-hosted WordPress** → Show export instructions
|
||||
- **Unknown** → Suggest file upload
|
||||
|
||||
### Step 2: Analyze
|
||||
|
||||
The source parses content and checks schema compatibility:
|
||||
|
||||
```
|
||||
Post Types:
|
||||
├── post (127) → posts [New collection]
|
||||
├── page (12) → pages [Existing, compatible]
|
||||
├── product (45) → products [Add 3 fields]
|
||||
└── revision (234) → [Skip - internal type]
|
||||
|
||||
Required Schema Changes:
|
||||
├── Create collection: posts
|
||||
├── Add fields to pages: featured_image
|
||||
└── Create collection: products
|
||||
```
|
||||
|
||||
Each post type shows its status:
|
||||
|
||||
| Status | Meaning |
|
||||
| -------------- | ---------------------------------------- |
|
||||
| Ready | Collection exists with compatible fields |
|
||||
| New collection | Will be created automatically |
|
||||
| Add fields | Collection exists, missing fields added |
|
||||
| Incompatible | Field type conflicts (manual fix needed) |
|
||||
|
||||
### Step 3: Prepare Schema
|
||||
|
||||
Click **Create Schema & Import** to:
|
||||
|
||||
1. Create new collections via SchemaRegistry
|
||||
2. Add missing fields with correct column types
|
||||
3. Set up content tables with indexes
|
||||
|
||||
### Step 4: Execute Import
|
||||
|
||||
Content imports sequentially:
|
||||
|
||||
- Gutenberg/HTML converted to Portable Text
|
||||
- WordPress status mapped to EmDash status
|
||||
- WordPress authors mapped to ownership (`authorId`) and presentation bylines
|
||||
- Taxonomies created and linked
|
||||
- Reusable blocks (`wp_block`) imported as [Sections](/guides/sections/)
|
||||
- Progress shown in real-time
|
||||
|
||||
Author import behavior:
|
||||
|
||||
- If an author mapping points to an EmDash user, ownership is set to that user and a linked byline is created/reused for the same user.
|
||||
- If there is no user mapping, a guest byline is created/reused from the WordPress author identity.
|
||||
- Imported entries get ordered byline credits, with the first credit set as `primaryBylineId`.
|
||||
|
||||
### Step 5: Media Import (Optional)
|
||||
|
||||
After content, optionally import media:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Analysis** — Shows attachment counts by type
|
||||
|
||||
```
|
||||
Media found:
|
||||
├── Images: 75 files
|
||||
├── Video: 10 files
|
||||
└── Other: 4 files
|
||||
```
|
||||
|
||||
2. **Download** — Streams from WordPress URLs with progress
|
||||
|
||||
```
|
||||
Importing media...
|
||||
├── 45 of 89 (50%)
|
||||
├── Current: vacation-photo.jpg
|
||||
└── Status: Uploading
|
||||
```
|
||||
|
||||
3. **Rewrite URLs** — Content automatically updated with new URLs
|
||||
|
||||
</Steps>
|
||||
|
||||
Media import uses content hashing (xxHash64) for deduplication. The same image used in multiple posts is stored once.
|
||||
|
||||
## Source Interface
|
||||
|
||||
Import sources implement a standard interface:
|
||||
|
||||
```typescript
|
||||
interface ImportSource {
|
||||
/** Unique identifier */
|
||||
id: string;
|
||||
|
||||
/** Display name */
|
||||
name: string;
|
||||
|
||||
/** Probe a URL (optional) */
|
||||
probe?(url: string): Promise<SourceProbeResult | null>;
|
||||
|
||||
/** Analyze content from this source */
|
||||
analyze(input: SourceInput, context: ImportContext): Promise<ImportAnalysis>;
|
||||
|
||||
/** Stream content items */
|
||||
fetchContent(input: SourceInput, options: FetchOptions): AsyncGenerator<NormalizedItem>;
|
||||
}
|
||||
```
|
||||
|
||||
### Input Types
|
||||
|
||||
Sources accept different input types:
|
||||
|
||||
```typescript
|
||||
// File upload (WXR)
|
||||
{ type: "file", file: File }
|
||||
|
||||
// URL with optional token (REST API)
|
||||
{ type: "url", url: string, token?: string }
|
||||
|
||||
// OAuth connection (WordPress.com)
|
||||
{ type: "oauth", url: string, accessToken: string }
|
||||
```
|
||||
|
||||
### Normalized Output
|
||||
|
||||
All sources produce the same normalized format:
|
||||
|
||||
```typescript
|
||||
interface NormalizedItem {
|
||||
sourceId: string | number;
|
||||
postType: string;
|
||||
status: "publish" | "draft" | "pending" | "private" | "future";
|
||||
slug: string;
|
||||
title: string;
|
||||
content: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
date: Date;
|
||||
author?: string;
|
||||
authors?: string[];
|
||||
categories?: string[];
|
||||
tags?: string[];
|
||||
meta?: Record<string, unknown>;
|
||||
featuredImage?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
The import system exposes these endpoints:
|
||||
|
||||
### Probe URL
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/probe
|
||||
Content-Type: application/json
|
||||
|
||||
{ "url": "https://example.com" }
|
||||
```
|
||||
|
||||
Returns detected platform and suggested action.
|
||||
|
||||
### Analyze WXR
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/analyze
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: [WordPress export .xml]
|
||||
```
|
||||
|
||||
Returns post type analysis with schema compatibility.
|
||||
|
||||
### Prepare Schema
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/prepare
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"postTypes": [
|
||||
{ "name": "post", "collection": "posts", "enabled": true }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Creates collections and fields.
|
||||
|
||||
### Execute Import
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/execute
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: [WordPress export .xml]
|
||||
config: { "postTypeMappings": { "post": { "collection": "posts" } } }
|
||||
```
|
||||
|
||||
Imports content to specified collections.
|
||||
|
||||
### Import Media
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/media
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"attachments": [{ "id": 123, "url": "https://..." }],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
Streams NDJSON progress updates during download/upload.
|
||||
|
||||
### Rewrite URLs
|
||||
|
||||
```http
|
||||
POST /_emdash/api/import/wordpress/rewrite-urls
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"urlMap": { "https://old.com/image.jpg": "/_emdash/media/abc123" }
|
||||
}
|
||||
```
|
||||
|
||||
Updates Portable Text content with new media URLs.
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Recoverable Errors
|
||||
|
||||
- **Network timeout** — Retried with backoff
|
||||
- **Single item parse failure** — Logged, skipped, import continues
|
||||
- **Media download failure** — Marked for manual handling
|
||||
|
||||
### Fatal Errors
|
||||
|
||||
- **Invalid file format** — Import stops with error message
|
||||
- **Database connection lost** — Import pauses, allows resume
|
||||
- **Storage quota exceeded** — Import stops, shows usage
|
||||
|
||||
### Error Report
|
||||
|
||||
After import:
|
||||
|
||||
```
|
||||
Import Complete
|
||||
|
||||
✓ 125 posts imported
|
||||
✓ 12 pages imported
|
||||
✓ 85 media references recorded
|
||||
|
||||
⚠ 2 items had warnings:
|
||||
- Post "Special Characters ñ" - title encoding fixed
|
||||
- Page "About" - duplicate slug renamed to "about-1"
|
||||
|
||||
✗ 1 item failed:
|
||||
- Post ID 456 - content parsing error (saved as draft)
|
||||
```
|
||||
|
||||
Failed items are saved as drafts with original content in `_importError` for review.
|
||||
|
||||
## Building Custom Sources
|
||||
|
||||
Create a source for other platforms:
|
||||
|
||||
```typescript title="src/import/custom-source.ts"
|
||||
import type { ImportSource } from "emdash/import";
|
||||
|
||||
export const mySource: ImportSource = {
|
||||
id: "my-platform",
|
||||
name: "My Platform",
|
||||
description: "Import from My Platform",
|
||||
icon: "globe",
|
||||
canProbe: true,
|
||||
|
||||
async probe(url) {
|
||||
// Check if URL matches your platform
|
||||
const response = await fetch(`${url}/api/info`);
|
||||
if (!response.ok) return null;
|
||||
|
||||
return {
|
||||
sourceId: "my-platform",
|
||||
confidence: "definite",
|
||||
detected: { platform: "my-platform" },
|
||||
// ...
|
||||
};
|
||||
},
|
||||
|
||||
async analyze(input, context) {
|
||||
// Parse and analyze content
|
||||
// Return ImportAnalysis
|
||||
},
|
||||
|
||||
async *fetchContent(input, options) {
|
||||
// Yield NormalizedItem for each content piece
|
||||
for (const item of items) {
|
||||
yield {
|
||||
sourceId: item.id,
|
||||
postType: "post",
|
||||
title: item.title,
|
||||
content: convertToPortableText(item.body),
|
||||
// ...
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Register the source in your EmDash configuration:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { mySource } from "./src/import/custom-source";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
import: {
|
||||
sources: [mySource],
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[WordPress Migration](/migration/from-wordpress/)** — Complete WordPress migration guide
|
||||
- **[Plugin Porting](/migration/plugin-porting/)** — Port WordPress plugins to EmDash
|
||||
256
docs/src/content/docs/migration/from-wordpress.mdx
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
title: Migrate from WordPress
|
||||
description: Import your WordPress content into EmDash with a step-by-step guide.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash provides a complete migration path from WordPress. Import your posts, pages, media, and taxonomies through the admin dashboard—no CLI required.
|
||||
|
||||
## Before You Begin
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Export your content" icon="document">
|
||||
In WordPress, go to **Tools → Export** and download a complete export file (.xml).
|
||||
</Card>
|
||||
<Card title="Back up your site" icon="warning">
|
||||
Keep your WordPress site running until you verify the migration succeeded.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Import Methods
|
||||
|
||||
EmDash supports three methods for importing WordPress content:
|
||||
|
||||
| Method | Best for | Includes drafts | Requires auth |
|
||||
| ---------------- | ------------------------------ | --------------- | ------------- |
|
||||
| WXR file upload | Complete migrations | Yes | No |
|
||||
| WordPress.com | WordPress.com hosted sites | Yes | OAuth |
|
||||
| REST API (probe) | Checking content before export | No | Optional |
|
||||
|
||||
The WXR file upload is recommended for most migrations. It captures all content, including drafts, custom fields, and private posts.
|
||||
|
||||
## WXR File Import
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Export from WordPress**
|
||||
|
||||
In your WordPress admin, go to **Tools → Export → All content → Download Export File**.
|
||||
|
||||
2. **Open the Import wizard**
|
||||
|
||||
In EmDash, go to **Admin → Settings → Import → WordPress**.
|
||||
|
||||
3. **Upload your export file**
|
||||
|
||||
Drag and drop your `.xml` file or click to browse. The file is parsed in your browser.
|
||||
|
||||
4. **Review detected content**
|
||||
|
||||
The wizard shows what was found:
|
||||
|
||||
```
|
||||
Found in export:
|
||||
├── Posts: 127 → posts [New collection]
|
||||
├── Pages: 12 → pages [Add fields]
|
||||
└── Media: 89 attachments
|
||||
```
|
||||
|
||||
5. **Configure mappings**
|
||||
|
||||
Toggle which post types to import. EmDash automatically:
|
||||
- Creates new collections for unmapped post types
|
||||
- Adds missing fields to existing collections
|
||||
- Warns about field type conflicts
|
||||
|
||||
6. **Execute the import**
|
||||
|
||||
Click **Import Content**. Progress displays as each item is processed.
|
||||
|
||||
7. **Import media (optional)**
|
||||
|
||||
After content imports, choose whether to download media files. EmDash:
|
||||
- Downloads from your WordPress URLs
|
||||
- Deduplicates by content hash
|
||||
- Rewrites URLs in your content automatically
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside type="tip">
|
||||
Re-running the import is safe. Items are matched by WordPress ID, so you won't create duplicates.
|
||||
</Aside>
|
||||
|
||||
## Content Conversion
|
||||
|
||||
### Gutenberg to Portable Text
|
||||
|
||||
EmDash converts Gutenberg blocks to [Portable Text](https://github.com/portabletext/portabletext), a structured content format.
|
||||
|
||||
| Gutenberg Block | Portable Text | Notes |
|
||||
| ---------------- | ---------------------------- | ----------------------------- |
|
||||
| `core/paragraph` | `block` style="normal" | Inline marks preserved |
|
||||
| `core/heading` | `block` style="h1-h6" | Level from block attributes |
|
||||
| `core/image` | `image` block | Media reference updated |
|
||||
| `core/list` | `block` with `listItem` type | Ordered and unordered |
|
||||
| `core/quote` | `block` style="blockquote" | Citation included |
|
||||
| `core/code` | `code` block | Language attribute preserved |
|
||||
| `core/embed` | `embed` block | URL and provider stored |
|
||||
| `core/gallery` | `gallery` block | Array of image references |
|
||||
| `core/columns` | `columns` block | Nested content preserved |
|
||||
| Unknown blocks | `htmlBlock` | Raw HTML preserved for review |
|
||||
|
||||
Unknown blocks are stored as `htmlBlock` with the original HTML and block metadata. You can review and convert these manually or create custom Portable Text components to render them.
|
||||
|
||||
### Classic Editor Content
|
||||
|
||||
HTML from the Classic Editor is converted to Portable Text blocks. Inline styles (`<strong>`, `<em>`, `<a>`) become marks on spans.
|
||||
|
||||
### Status Mapping
|
||||
|
||||
| WordPress Status | EmDash Status |
|
||||
| ---------------- | --------------- |
|
||||
| `publish` | `published` |
|
||||
| `draft` | `draft` |
|
||||
| `pending` | `pending` |
|
||||
| `private` | `private` |
|
||||
| `future` | `scheduled` |
|
||||
| `trash` | `archived` |
|
||||
|
||||
## Taxonomy Import
|
||||
|
||||
Categories and tags import as taxonomies with hierarchy preserved:
|
||||
|
||||
```
|
||||
WordPress: EmDash:
|
||||
├── Categories (hierarchical) ├── taxonomies table
|
||||
│ ├── News │ ├── category/news
|
||||
│ │ ├── Local │ ├── category/local (parent: news)
|
||||
│ │ └── World │ ├── category/world (parent: news)
|
||||
│ └── Sports │ └── category/sports
|
||||
└── Tags (flat) └── content_taxonomies junction
|
||||
├── featured ├── tag/featured
|
||||
└── breaking └── tag/breaking
|
||||
```
|
||||
|
||||
## Custom Fields and ACF
|
||||
|
||||
WordPress post meta and ACF fields are analyzed during import:
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Analysis phase**
|
||||
|
||||
The wizard detects custom fields and suggests EmDash field types:
|
||||
|
||||
```
|
||||
Custom Fields:
|
||||
├── subtitle (string, 45 posts)
|
||||
├── _yoast_wpseo_title → seo.title (string, 127 posts)
|
||||
├── _thumbnail_id → featuredImage (reference, 89 posts)
|
||||
└── price (number, 23 posts)
|
||||
```
|
||||
|
||||
2. **Field mapping**
|
||||
|
||||
Internal WordPress fields (starting with `_edit_`, `_wp_`) are hidden by default. SEO plugin fields map to an `seo` object.
|
||||
|
||||
3. **Type inference**
|
||||
|
||||
EmDash infers field types from values:
|
||||
- Numeric strings → `number`
|
||||
- `"1"`, `"0"`, `"true"`, `"false"` → `boolean`
|
||||
- ISO dates → `date`
|
||||
- Serialized PHP/JSON → `json`
|
||||
- WordPress IDs (e.g., `_thumbnail_id`) → `reference`
|
||||
|
||||
</Steps>
|
||||
|
||||
<Aside>
|
||||
ACF repeater fields and flexible content import as JSON. Create matching Portable Text or array
|
||||
fields in EmDash to structure this data.
|
||||
</Aside>
|
||||
|
||||
## URL Redirects
|
||||
|
||||
After import, EmDash generates a redirect map:
|
||||
|
||||
```json
|
||||
{
|
||||
"redirects": [
|
||||
{ "from": "/?p=123", "to": "/posts/hello-world" },
|
||||
{ "from": "/2024/01/hello-world/", "to": "/posts/hello-world" },
|
||||
{ "from": "/category/news/", "to": "/categories/news" }
|
||||
],
|
||||
"feeds": [
|
||||
{ "from": "/feed/", "to": "/rss.xml" },
|
||||
{ "from": "/feed/atom/", "to": "/atom.xml" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Apply these redirects to:
|
||||
|
||||
- Cloudflare redirect rules
|
||||
- Your hosting platform's redirect config
|
||||
- Astro's `redirects` option in `astro.config.mjs`
|
||||
|
||||
## Concept Mapping Reference
|
||||
|
||||
Use this table when adapting WordPress patterns to EmDash:
|
||||
|
||||
| WordPress | EmDash | Notes |
|
||||
| ----------------------- | ------------------------------------ | ------------------------------ |
|
||||
| `register_post_type()` | Collection in admin UI | Created via dashboard or API |
|
||||
| `register_taxonomy()` | Taxonomy or array field | Depends on complexity |
|
||||
| `register_meta()` | Field in collection schema | Typed, not key-value |
|
||||
| `WP_Query` | `getCollection(filters)` | Runtime queries |
|
||||
| `get_post()` | `getEntry(collection, id)` | Returns entry or null |
|
||||
| `wp_insert_post()` | `POST /_emdash/api/content/{type}` | REST API |
|
||||
| `the_content` | `<PortableText value={...} />` | Portable Text rendering |
|
||||
| `add_shortcode()` | Portable Text custom block | Custom component renderer |
|
||||
| `register_block_type()` | Portable Text custom block | Same as shortcodes |
|
||||
| `add_menu_page()` | Plugin admin page | Under `/_emdash/admin/` |
|
||||
| `add_action/filter()` | Plugin hooks | `hooks.content:beforeSave` |
|
||||
| `wp_options` | `ctx.kv` | Key-value store |
|
||||
| `wp_postmeta` | Collection fields | Structured, not key-value |
|
||||
| `$wpdb` | `ctx.storage` | Direct storage access |
|
||||
| Categories/Tags | Taxonomies | Hierarchical support preserved |
|
||||
|
||||
## API Import (Advanced)
|
||||
|
||||
The WordPress import is available through the admin dashboard and the REST API. Use the admin dashboard import wizard for the best experience — it provides field mapping, conflict resolution, and progress tracking.
|
||||
|
||||
The import API endpoints are under `/_emdash/api/import/wordpress/` for programmatic access.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "XML parsing error"
|
||||
|
||||
The export file may be corrupted or incomplete. Re-export from WordPress.
|
||||
|
||||
### Media download failures
|
||||
|
||||
Some images may be behind authentication or have moved. The import continues, and failed URLs are logged for manual handling.
|
||||
|
||||
### Field type conflicts
|
||||
|
||||
If an existing collection has a field with an incompatible type, the import wizard shows the conflict. Either:
|
||||
|
||||
- Rename the EmDash field
|
||||
- Change the WordPress field mapping
|
||||
- Delete and recreate the collection
|
||||
|
||||
### Large exports
|
||||
|
||||
For exports over 100MB, consider:
|
||||
|
||||
1. Export post types separately in WordPress
|
||||
2. Import each file sequentially
|
||||
3. Use the CLI with `--resume` for reliability
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Content Import](/migration/content-import/)** — Other import sources and methods
|
||||
- **[Plugin Porting](/migration/plugin-porting/)** — Migrate WordPress plugin functionality
|
||||
- **[Working with Content](/guides/working-with-content/)** — Query and render your imported content
|
||||
424
docs/src/content/docs/migration/porting-plugins.mdx
Normal file
@@ -0,0 +1,424 @@
|
||||
---
|
||||
title: Porting WordPress Plugins
|
||||
description: Convert WordPress plugins to EmDash plugins using the Plugin API
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Many WordPress plugins can be ported to EmDash. The plugin model is different—TypeScript instead of PHP, hooks instead of actions/filters, structured storage instead of wp_options—but most functionality maps cleanly.
|
||||
|
||||
## Portability Assessment
|
||||
|
||||
Not all plugins make sense to port. Assess candidates before starting.
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Good candidates" icon="approve-check">
|
||||
Custom fields, SEO plugins, content processors, admin UI extensions, analytics, social sharing, forms
|
||||
</Card>
|
||||
<Card title="Poor candidates" icon="close">
|
||||
Multisite features, WooCommerce/Gutenberg integrations, plugins that patch WordPress core internals
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Plugin Structure Comparison
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```
|
||||
wp-content/plugins/my-plugin/
|
||||
├── my-plugin.php # Main file with plugin header
|
||||
├── includes/
|
||||
│ ├── class-admin.php
|
||||
│ └── class-api.php
|
||||
└── admin/
|
||||
└── js/
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```
|
||||
my-plugin/
|
||||
├── src/
|
||||
│ ├── index.ts # Plugin definition (definePlugin)
|
||||
│ └── admin.tsx # Admin UI exports (React)
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Hooks Mapping
|
||||
|
||||
WordPress uses `add_action()` and `add_filter()` with string hook names. EmDash uses typed hooks declared in the plugin definition.
|
||||
|
||||
### Lifecycle Hooks
|
||||
|
||||
| WordPress | EmDash | Notes |
|
||||
| ---------------------------- | ------------------- | ---------------------------------------- |
|
||||
| `register_activation_hook()` | `plugin:install` | Runs once on first install |
|
||||
| Plugin enabled | `plugin:activate` | Runs when enabled |
|
||||
| Plugin disabled | `plugin:deactivate` | Runs when disabled |
|
||||
| `register_uninstall_hook()` | `plugin:uninstall` | `event.deleteData` indicates user choice |
|
||||
|
||||
### Content Hooks
|
||||
|
||||
| WordPress | EmDash | Notes |
|
||||
| --------------------- | ---------------------- | ------------------------------------------ |
|
||||
| `wp_insert_post_data` | `content:beforeSave` | Return modified content or throw to cancel |
|
||||
| `save_post` | `content:afterSave` | Side effects after save |
|
||||
| `before_delete_post` | `content:beforeDelete` | Return `false` to cancel |
|
||||
| `deleted_post` | `content:afterDelete` | Cleanup after deletion |
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
add_action('save_post', function($post_id, $post, $update) {
|
||||
if ($post->post_type !== 'product') return;
|
||||
|
||||
$price = get_post_meta($post_id, 'price', true);
|
||||
if ($price > 1000) {
|
||||
update_post_meta($post_id, 'is_premium', true);
|
||||
}
|
||||
|
||||
}, 10, 3);
|
||||
|
||||
````
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
if (event.collection !== "products") return;
|
||||
|
||||
const price = event.content.price as number;
|
||||
if (price > 1000) {
|
||||
await ctx.kv.set(`premium:${event.content.id}`, true);
|
||||
}
|
||||
},
|
||||
}
|
||||
````
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Media Hooks
|
||||
|
||||
| WordPress | EmDash | Notes |
|
||||
| ---------------------------- | -------------------- | --------------------- |
|
||||
| `wp_handle_upload_prefilter` | `media:beforeUpload` | Validate or transform |
|
||||
| `add_attachment` | `media:afterUpload` | React after upload |
|
||||
|
||||
## Storage Mapping
|
||||
|
||||
### Options API → KV Store
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
$api_key = get_option('my_plugin_api_key', '');
|
||||
update_option('my_plugin_api_key', 'abc123');
|
||||
delete_option('my_plugin_api_key');
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript
|
||||
const apiKey = await ctx.kv.get<string>("settings:apiKey") ?? "";
|
||||
await ctx.kv.set("settings:apiKey", "abc123");
|
||||
await ctx.kv.delete("settings:apiKey");
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside>
|
||||
Use `settings:` prefix for user-configurable values and `state:` prefix for internal plugin state.
|
||||
</Aside>
|
||||
|
||||
### Custom Tables → Storage Collections
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'my_plugin_items';
|
||||
|
||||
// Insert
|
||||
$wpdb->insert($table, ['name' => 'Item 1', 'status' => 'active']);
|
||||
|
||||
// Query
|
||||
$items = $wpdb->get_results(
|
||||
"SELECT \* FROM $table WHERE status = 'active' LIMIT 10"
|
||||
);
|
||||
|
||||
````
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript
|
||||
// Declare in plugin definition
|
||||
storage: {
|
||||
items: {
|
||||
indexes: ["status", "createdAt"],
|
||||
},
|
||||
},
|
||||
|
||||
// In hooks or routes:
|
||||
await ctx.storage.items.put("item-1", {
|
||||
name: "Item 1",
|
||||
status: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
const result = await ctx.storage.items.query({
|
||||
where: { status: "active" },
|
||||
limit: 10,
|
||||
});
|
||||
````
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Settings Schema
|
||||
|
||||
WordPress uses the Settings API for admin forms. EmDash uses a declarative schema that auto-generates UI.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
add_action('admin_init', function() {
|
||||
register_setting('my_plugin', 'my_plugin_api_key');
|
||||
add_settings_section('main', 'Settings', null, 'my-plugin');
|
||||
add_settings_field('api_key', 'API Key', function() {
|
||||
$value = get_option('my_plugin_api_key');
|
||||
echo '<input type="text" name="my_plugin_api_key"
|
||||
value="' . esc_attr($value) . '">';
|
||||
}, 'my-plugin', 'main');
|
||||
});
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
apiKey: {
|
||||
type: "secret",
|
||||
label: "API Key",
|
||||
description: "Your API key from the dashboard",
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
default: true,
|
||||
},
|
||||
limit: {
|
||||
type: "number",
|
||||
label: "Item Limit",
|
||||
default: 100,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Admin UI
|
||||
|
||||
WordPress admin pages are PHP. EmDash uses React components.
|
||||
|
||||
```tsx title="src/admin.tsx"
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const widgets = {
|
||||
summary: function SummaryWidget() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/_emdash/api/plugins/my-plugin/status")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setCount(data.count));
|
||||
}, []);
|
||||
|
||||
return <div>Total items: {count}</div>;
|
||||
},
|
||||
};
|
||||
|
||||
export const pages = {
|
||||
settings: function SettingsPage() {
|
||||
// React component for settings page
|
||||
return <div>Settings content</div>;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Register in the plugin definition:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
admin: {
|
||||
entry: "@my-org/my-plugin/admin",
|
||||
pages: [{ path: "/settings", label: "Dashboard" }],
|
||||
widgets: [{ id: "summary", title: "Summary", size: "half" }],
|
||||
},
|
||||
```
|
||||
|
||||
## REST API → Plugin Routes
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
register_rest_route('my-plugin/v1', '/items', [
|
||||
'methods' => 'GET',
|
||||
'callback' => function($request) {
|
||||
global $wpdb;
|
||||
$items = $wpdb->get_results("SELECT * FROM items LIMIT 50");
|
||||
return new WP_REST_Response($items);
|
||||
},
|
||||
]);
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript
|
||||
routes: {
|
||||
items: {
|
||||
handler: async (ctx) => {
|
||||
const result = await ctx.storage.items.query({ limit: 50 });
|
||||
return { items: result.items };
|
||||
},
|
||||
},
|
||||
},
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Routes are available at `/_emdash/api/plugins/{plugin-id}/{route-name}`.
|
||||
|
||||
## Porting Process
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Analyze the WordPress plugin**
|
||||
|
||||
Document what it does: hooks, database operations, admin pages, REST endpoints.
|
||||
|
||||
2. **Map to EmDash concepts**
|
||||
|
||||
WordPress hooks → EmDash hooks. `wp_options` → `ctx.kv`. Custom tables → Storage collections. Admin pages → React components. REST endpoints → Plugin routes.
|
||||
|
||||
3. **Create the plugin skeleton**
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export function createPlugin() {
|
||||
return definePlugin({
|
||||
id: "my-ported-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
storage: {},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
admin: {},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
4. **Implement in order**
|
||||
|
||||
Storage → Hooks → Admin UI → Routes
|
||||
|
||||
5. **Test thoroughly**
|
||||
|
||||
Verify hooks fire correctly, storage works, and admin UI renders.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Example: Read Time Plugin
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php
|
||||
add_filter('wp_insert_post_data', function($data, $postarr) {
|
||||
if ($data['post_type'] !== 'post') return $data;
|
||||
|
||||
$content = strip_tags($data['post_content']);
|
||||
$word_count = str_word_count($content);
|
||||
$read_time = ceil($word_count / 200);
|
||||
|
||||
if (!empty($postarr['ID'])) {
|
||||
update_post_meta($postarr['ID'], '_read_time', $read_time);
|
||||
}
|
||||
return $data;
|
||||
|
||||
}, 10, 2);
|
||||
|
||||
````
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```typescript title="src/index.ts"
|
||||
export function createPlugin() {
|
||||
return definePlugin({
|
||||
id: "read-time",
|
||||
version: "1.0.0",
|
||||
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
wordsPerMinute: {
|
||||
type: "number",
|
||||
label: "Words per minute",
|
||||
default: 200,
|
||||
min: 100,
|
||||
max: 400,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
if (event.collection !== "posts") return;
|
||||
|
||||
const wpm = await ctx.kv.get<number>("settings:wordsPerMinute") ?? 200;
|
||||
const text = JSON.stringify(event.content.body || "");
|
||||
const readTime = Math.ceil(text.split(/\s+/).length / wpm);
|
||||
|
||||
return { ...event.content, readTime };
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
````
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
<Aside type="tip" title="AI-Assisted Porting">
|
||||
Plugin porting is more nuanced than theme porting, but AI agents still help significantly. Provide
|
||||
the WordPress plugin code along with EmDash's Plugin API documentation, and the agent can
|
||||
generate a reasonable first draft. Complex plugins may need multiple iterations.
|
||||
</Aside>
|
||||
|
||||
## Capabilities
|
||||
|
||||
Plugins must declare required capabilities for security sandboxing:
|
||||
|
||||
| Capability | Provides | Use Case |
|
||||
| ----------------- | ----------------------------- | ------------------- |
|
||||
| `network:request` | `ctx.http.fetch()` | External API calls |
|
||||
| `content:read` | `ctx.content.get()`, `list()` | Reading CMS content |
|
||||
| `content:write` | `ctx.content.create()`, etc. | Modifying content |
|
||||
| `media:read` | `ctx.media.get()`, `list()` | Reading media |
|
||||
| `media:write` | `ctx.media.getUploadUrl()` | Uploading media |
|
||||
|
||||
## Common Gotchas
|
||||
|
||||
**No global state** — Use storage instead of global variables.
|
||||
|
||||
**Async everything** — Always `await` storage and API calls.
|
||||
|
||||
**No direct SQL** — Use structured storage collections.
|
||||
|
||||
**No file system** — Use the media API for files.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Hooks Reference](/plugins/hooks/) — All hooks with signatures
|
||||
- [Storage API](/plugins/storage/) — Collections and queries
|
||||
- [Settings](/plugins/settings/) — Settings schema and KV store
|
||||
- [Admin UI](/plugins/admin-ui/) — Building admin pages
|
||||
401
docs/src/content/docs/plugins/admin-ui.mdx
Normal file
@@ -0,0 +1,401 @@
|
||||
---
|
||||
title: Admin UI
|
||||
description: Add admin pages and dashboard widgets to the EmDash admin panel.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins can extend the admin panel with custom pages and dashboard widgets. These are React components that render alongside core admin functionality.
|
||||
|
||||
## Admin Entry Point
|
||||
|
||||
Plugins with admin UI export components from an `admin` entry point:
|
||||
|
||||
```typescript title="src/admin.tsx"
|
||||
import { SEOSettingsPage } from "./components/SEOSettingsPage";
|
||||
import { SEODashboardWidget } from "./components/SEODashboardWidget";
|
||||
|
||||
// Dashboard widgets
|
||||
export const widgets = {
|
||||
"seo-overview": SEODashboardWidget,
|
||||
};
|
||||
|
||||
// Admin pages
|
||||
export const pages = {
|
||||
"/settings": SEOSettingsPage,
|
||||
};
|
||||
```
|
||||
|
||||
Configure the entry point in `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./admin": "./dist/admin.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference it in your plugin definition:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
definePlugin({
|
||||
id: "seo",
|
||||
version: "1.0.0",
|
||||
|
||||
admin: {
|
||||
entry: "@my-org/plugin-seo/admin",
|
||||
pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
|
||||
widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Admin Pages
|
||||
|
||||
Admin pages are React components that receive the plugin context via hooks.
|
||||
|
||||
### Page Definition
|
||||
|
||||
Define pages in `admin.pages`:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
pages: [
|
||||
{
|
||||
path: "/settings", // URL path (relative to plugin base)
|
||||
label: "Settings", // Sidebar label
|
||||
icon: "settings", // Icon name (optional)
|
||||
},
|
||||
{
|
||||
path: "/reports",
|
||||
label: "Reports",
|
||||
icon: "chart",
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`.
|
||||
|
||||
### Page Component
|
||||
|
||||
```typescript title="src/components/SettingsPage.tsx"
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePluginAPI } from "@emdash-cms/admin";
|
||||
|
||||
export function SettingsPage() {
|
||||
const api = usePluginAPI();
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("settings").then(setSettings);
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
await api.post("settings/save", settings);
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Plugin Settings</h1>
|
||||
|
||||
<label>
|
||||
Site Title
|
||||
<input
|
||||
type="text"
|
||||
value={settings.siteTitle || ""}
|
||||
onChange={(e) => setSettings({ ...settings, siteTitle: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled ?? true}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
|
||||
<button onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Settings"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Plugin API Hook
|
||||
|
||||
Use `usePluginAPI()` to call your plugin's routes:
|
||||
|
||||
```typescript
|
||||
import { usePluginAPI } from "@emdash-cms/admin";
|
||||
|
||||
function MyComponent() {
|
||||
const api = usePluginAPI();
|
||||
|
||||
// GET request to plugin route
|
||||
const data = await api.get("status");
|
||||
|
||||
// POST request with body
|
||||
await api.post("settings/save", { enabled: true });
|
||||
|
||||
// With URL parameters
|
||||
const result = await api.get("history?limit=50");
|
||||
}
|
||||
```
|
||||
|
||||
The hook automatically adds the plugin ID prefix to route URLs.
|
||||
|
||||
## Dashboard Widgets
|
||||
|
||||
Widgets appear on the admin dashboard and provide at-a-glance information.
|
||||
|
||||
### Widget Definition
|
||||
|
||||
Define widgets in `admin.widgets`:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
widgets: [
|
||||
{
|
||||
id: "seo-overview", // Unique widget ID
|
||||
title: "SEO Overview", // Widget title (optional)
|
||||
size: "half", // "full" | "half" | "third"
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Component
|
||||
|
||||
```typescript title="src/components/SEOWidget.tsx"
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePluginAPI } from "@emdash-cms/admin";
|
||||
|
||||
export function SEOWidget() {
|
||||
const api = usePluginAPI();
|
||||
const [data, setData] = useState({ score: 0, issues: [] });
|
||||
|
||||
useEffect(() => {
|
||||
api.get("analyze").then(setData);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="widget-content">
|
||||
<div className="score">{data.score}%</div>
|
||||
<ul>
|
||||
{data.issues.map((issue, i) => (
|
||||
<li key={i}>{issue.message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Sizes
|
||||
|
||||
| Size | Description |
|
||||
| ------- | ------------------------- |
|
||||
| `full` | Full dashboard width |
|
||||
| `half` | Half dashboard width |
|
||||
| `third` | One-third dashboard width |
|
||||
|
||||
Widgets wrap automatically based on screen width.
|
||||
|
||||
## Export Structure
|
||||
|
||||
The admin entry point exports two objects:
|
||||
|
||||
```typescript title="src/admin.tsx"
|
||||
import { SettingsPage } from "./components/SettingsPage";
|
||||
import { ReportsPage } from "./components/ReportsPage";
|
||||
import { StatusWidget } from "./components/StatusWidget";
|
||||
import { OverviewWidget } from "./components/OverviewWidget";
|
||||
|
||||
// Pages keyed by path
|
||||
export const pages = {
|
||||
"/settings": SettingsPage,
|
||||
"/reports": ReportsPage,
|
||||
};
|
||||
|
||||
// Widgets keyed by ID
|
||||
export const widgets = {
|
||||
status: StatusWidget,
|
||||
overview: OverviewWidget,
|
||||
};
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Page paths in `pages` must match the `path` values in `admin.pages`. Widget keys must match the
|
||||
`id` values in `admin.widgets`.
|
||||
</Aside>
|
||||
|
||||
## Using Admin Components
|
||||
|
||||
EmDash provides pre-built components for common patterns:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Card,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Toggle,
|
||||
Table,
|
||||
Pagination,
|
||||
Alert,
|
||||
Loading
|
||||
} from "@emdash-cms/admin";
|
||||
|
||||
function SettingsPage() {
|
||||
return (
|
||||
<Card title="Settings">
|
||||
<Input label="API Key" type="password" />
|
||||
<Toggle label="Enabled" defaultChecked />
|
||||
<Button variant="primary">Save</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Auto-Generated Settings UI
|
||||
|
||||
If your plugin only needs a settings form, use `admin.settingsSchema` without custom components:
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
apiKey: { type: "secret", label: "API Key" },
|
||||
enabled: { type: "boolean", label: "Enabled", default: true }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
EmDash generates a settings page automatically. Add custom pages only for functionality beyond basic settings.
|
||||
|
||||
## Navigation
|
||||
|
||||
Plugin pages appear in the admin sidebar under the plugin name. The order matches the `admin.pages` array.
|
||||
|
||||
```typescript
|
||||
admin: {
|
||||
pages: [
|
||||
{ path: "/settings", label: "Settings", icon: "settings" }, // First
|
||||
{ path: "/history", label: "History", icon: "history" }, // Second
|
||||
{ path: "/reports", label: "Reports", icon: "chart" }, // Third
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
Admin components need a separate build entry point. Configure your bundler:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="tsdown">
|
||||
```typescript title="tsdown.config.ts"
|
||||
export default {
|
||||
entry: {
|
||||
index: "src/index.ts",
|
||||
admin: "src/admin.tsx"
|
||||
},
|
||||
format: "esm",
|
||||
dts: true,
|
||||
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
|
||||
};
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="tsup">
|
||||
```typescript title="tsup.config.ts"
|
||||
export default {
|
||||
entry: ["src/index.ts", "src/admin.tsx"],
|
||||
format: "esm",
|
||||
dts: true,
|
||||
external: ["react", "react-dom", "emdash", "@emdash-cms/admin"]
|
||||
};
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Keep React and EmDash admin as external dependencies to avoid bundling duplicates.
|
||||
|
||||
## Plugin Enable/Disable
|
||||
|
||||
When a plugin is disabled in the admin:
|
||||
|
||||
- Sidebar links are hidden
|
||||
- Dashboard widgets are not rendered
|
||||
- Admin pages return 404
|
||||
- Backend hooks still execute (for data safety)
|
||||
|
||||
Plugins can check their enabled state:
|
||||
|
||||
```typescript
|
||||
const enabled = await ctx.kv.get<boolean>("_emdash:enabled");
|
||||
```
|
||||
|
||||
## Example: Complete Admin UI
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "analytics",
|
||||
version: "1.0.0",
|
||||
|
||||
capabilities: ["network:request"],
|
||||
allowedHosts: ["api.analytics.example.com"],
|
||||
|
||||
storage: {
|
||||
events: { indexes: ["type", "createdAt"] },
|
||||
},
|
||||
|
||||
admin: {
|
||||
entry: "@my-org/plugin-analytics/admin",
|
||||
settingsSchema: {
|
||||
trackingId: { type: "string", label: "Tracking ID" },
|
||||
enabled: { type: "boolean", label: "Enabled", default: true },
|
||||
},
|
||||
pages: [
|
||||
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
|
||||
{ path: "/settings", label: "Settings", icon: "settings" },
|
||||
],
|
||||
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
|
||||
},
|
||||
|
||||
routes: {
|
||||
stats: {
|
||||
handler: async (ctx) => {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
const count = await ctx.storage.events!.count({
|
||||
createdAt: { gte: today },
|
||||
});
|
||||
return { today: count };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```typescript title="src/admin.tsx"
|
||||
import { EventsWidget } from "./components/EventsWidget";
|
||||
import { DashboardPage } from "./components/DashboardPage";
|
||||
import { SettingsPage } from "./components/SettingsPage";
|
||||
|
||||
export const widgets = {
|
||||
"events-today": EventsWidget,
|
||||
};
|
||||
|
||||
export const pages = {
|
||||
"/dashboard": DashboardPage,
|
||||
"/settings": SettingsPage,
|
||||
};
|
||||
```
|
||||
471
docs/src/content/docs/plugins/api-routes.mdx
Normal file
@@ -0,0 +1,471 @@
|
||||
---
|
||||
title: Plugin API Routes
|
||||
description: Expose REST endpoints from your plugin for admin UI and external integrations.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins can expose API routes for their admin UI components or external integrations. Routes receive the full plugin context and can access storage, KV, content, and media. Both native and sandboxed plugins support API routes.
|
||||
|
||||
<Aside type="note">
|
||||
Native and sandboxed plugins use slightly different handler signatures for routes. Native-format plugins receive a single `RouteContext` argument. Standard-format plugins (which can run in either mode) receive two arguments: `(routeCtx, pluginCtx)`. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for details on the two formats.
|
||||
</Aside>
|
||||
|
||||
## Defining Routes
|
||||
|
||||
Define routes in the `routes` object:
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from "emdash";
|
||||
import { z } from "astro/zod";
|
||||
|
||||
export default definePlugin({
|
||||
id: "forms",
|
||||
version: "1.0.0",
|
||||
|
||||
storage: {
|
||||
submissions: {
|
||||
indexes: ["formId", "status", "createdAt"],
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
// Simple route
|
||||
status: {
|
||||
handler: async (ctx) => {
|
||||
return { ok: true, plugin: ctx.plugin.id };
|
||||
},
|
||||
},
|
||||
|
||||
// Route with input validation
|
||||
submissions: {
|
||||
input: z.object({
|
||||
formId: z.string().optional(),
|
||||
limit: z.number().default(50),
|
||||
cursor: z.string().optional(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const { formId, limit, cursor } = ctx.input;
|
||||
|
||||
const result = await ctx.storage.submissions!.query({
|
||||
where: formId ? { formId } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items,
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Route URLs
|
||||
|
||||
Routes mount at `/_emdash/api/plugins/<plugin-id>/<route-name>`:
|
||||
|
||||
| Plugin ID | Route Name | URL |
|
||||
| --------- | --------------- | ------------------------------------------ |
|
||||
| `forms` | `status` | `/_emdash/api/plugins/forms/status` |
|
||||
| `forms` | `submissions` | `/_emdash/api/plugins/forms/submissions` |
|
||||
| `seo` | `settings/save` | `/_emdash/api/plugins/seo/settings/save` |
|
||||
|
||||
Route names can include slashes for nested paths.
|
||||
|
||||
## Route Handler
|
||||
|
||||
The handler receives a `RouteContext` with the plugin context plus request-specific data:
|
||||
|
||||
```typescript
|
||||
interface RouteContext extends PluginContext {
|
||||
input: TInput; // Validated input (from body or query params)
|
||||
request: Request; // Original Request object
|
||||
}
|
||||
```
|
||||
|
||||
### Return Values
|
||||
|
||||
Return any JSON-serializable value:
|
||||
|
||||
```typescript
|
||||
// Object
|
||||
return { success: true, data: items };
|
||||
|
||||
// Array
|
||||
return items;
|
||||
|
||||
// Primitive
|
||||
return 42;
|
||||
```
|
||||
|
||||
<Aside type="note">
|
||||
Routes always return JSON. The response has `Content-Type: application/json`.
|
||||
</Aside>
|
||||
|
||||
### Errors
|
||||
|
||||
Throw to return an error response:
|
||||
|
||||
```typescript
|
||||
handler: async (ctx) => {
|
||||
const item = await ctx.storage.items!.get(ctx.input.id);
|
||||
|
||||
if (!item) {
|
||||
throw new Error("Item not found");
|
||||
// Returns: { "error": "Item not found" } with 500 status
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
```
|
||||
|
||||
For custom status codes, throw a `Response`:
|
||||
|
||||
```typescript
|
||||
handler: async (ctx) => {
|
||||
const item = await ctx.storage.items!.get(ctx.input.id);
|
||||
|
||||
if (!item) {
|
||||
throw new Response(JSON.stringify({ error: "Not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return item;
|
||||
};
|
||||
```
|
||||
|
||||
## Input Validation
|
||||
|
||||
Use Zod schemas to validate and parse input:
|
||||
|
||||
```typescript
|
||||
import { z } from "astro/zod";
|
||||
|
||||
routes: {
|
||||
create: {
|
||||
input: z.object({
|
||||
title: z.string().min(1).max(200),
|
||||
email: z.string().email(),
|
||||
priority: z.enum(["low", "medium", "high"]).default("medium"),
|
||||
tags: z.array(z.string()).optional()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
// ctx.input is typed and validated
|
||||
const { title, email, priority, tags } = ctx.input;
|
||||
|
||||
await ctx.storage.items!.put(`item_${Date.now()}`, {
|
||||
title,
|
||||
email,
|
||||
priority,
|
||||
tags: tags ?? [],
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Invalid input returns a 400 error with validation details.
|
||||
|
||||
### Input Sources
|
||||
|
||||
Input is parsed from:
|
||||
|
||||
1. **POST/PUT/PATCH** — Request body (JSON)
|
||||
2. **GET/DELETE** — URL query parameters
|
||||
|
||||
```typescript
|
||||
// POST /plugins/forms/create
|
||||
// Body: { "title": "Hello", "email": "user@example.com" }
|
||||
|
||||
// GET /plugins/forms/list?limit=20&status=pending
|
||||
```
|
||||
|
||||
## HTTP Methods
|
||||
|
||||
Routes respond to all HTTP methods. Check `ctx.request.method` to handle them differently:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
item: {
|
||||
input: z.object({
|
||||
id: z.string()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const { id } = ctx.input;
|
||||
|
||||
switch (ctx.request.method) {
|
||||
case "GET":
|
||||
return await ctx.storage.items!.get(id);
|
||||
|
||||
case "DELETE":
|
||||
await ctx.storage.items!.delete(id);
|
||||
return { deleted: true };
|
||||
|
||||
default:
|
||||
throw new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Accessing the Request
|
||||
|
||||
The full `Request` object is available for advanced use cases:
|
||||
|
||||
```typescript
|
||||
handler: async (ctx) => {
|
||||
const { request } = ctx;
|
||||
|
||||
// Headers
|
||||
const auth = request.headers.get("Authorization");
|
||||
|
||||
// URL parameters
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page");
|
||||
|
||||
// Method
|
||||
if (request.method !== "POST") {
|
||||
throw new Response("POST required", { status: 405 });
|
||||
}
|
||||
|
||||
// Body (if not using input schema)
|
||||
const body = await request.json();
|
||||
};
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Settings Routes
|
||||
|
||||
Expose and update plugin settings:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
settings: {
|
||||
handler: async (ctx) => {
|
||||
const settings = await ctx.kv.list("settings:");
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const entry of settings) {
|
||||
result[entry.key.replace("settings:", "")] = entry.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
"settings/save": {
|
||||
input: z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
maxItems: z.number().optional()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const input = ctx.input;
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) {
|
||||
await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Paginated List
|
||||
|
||||
Return paginated results with cursor-based navigation:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
list: {
|
||||
input: z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
cursor: z.string().optional(),
|
||||
status: z.string().optional()
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const { limit, cursor, status } = ctx.input;
|
||||
|
||||
const result = await ctx.storage.items!.query({
|
||||
where: status ? { status } : undefined,
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit,
|
||||
cursor
|
||||
});
|
||||
|
||||
return {
|
||||
items: result.items.map(item => ({
|
||||
id: item.id,
|
||||
...item.data
|
||||
})),
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### External API Proxy
|
||||
|
||||
Proxy requests to external services (requires `network:request` capability):
|
||||
|
||||
```typescript
|
||||
definePlugin({
|
||||
id: "weather",
|
||||
version: "1.0.0",
|
||||
|
||||
capabilities: ["network:request"],
|
||||
allowedHosts: ["api.weather.example.com"],
|
||||
|
||||
routes: {
|
||||
forecast: {
|
||||
input: z.object({
|
||||
city: z.string(),
|
||||
}),
|
||||
handler: async (ctx) => {
|
||||
const apiKey = await ctx.kv.get<string>("settings:apiKey");
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("API key not configured");
|
||||
}
|
||||
|
||||
const response = await ctx.http!.fetch(
|
||||
`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
|
||||
{
|
||||
headers: { "X-API-Key": apiKey },
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Weather API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Action Endpoint
|
||||
|
||||
Trigger a one-off action:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
sync: {
|
||||
handler: async (ctx) => {
|
||||
ctx.log.info("Starting sync...");
|
||||
|
||||
const startTime = Date.now();
|
||||
let synced = 0;
|
||||
|
||||
// Do work...
|
||||
const items = await fetchExternalItems(ctx);
|
||||
for (const item of items) {
|
||||
await ctx.storage.items!.put(item.id, item);
|
||||
synced++;
|
||||
}
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
ctx.log.info("Sync complete", { synced, duration });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
synced,
|
||||
duration,
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Calling Routes from Admin UI
|
||||
|
||||
Use the `usePluginAPI()` hook in admin components:
|
||||
|
||||
```typescript
|
||||
import { usePluginAPI } from "@emdash-cms/admin";
|
||||
|
||||
function SettingsPage() {
|
||||
const api = usePluginAPI();
|
||||
|
||||
const handleSave = async (settings) => {
|
||||
await api.post("settings/save", settings);
|
||||
};
|
||||
|
||||
const loadSettings = async () => {
|
||||
return api.get("settings");
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The hook automatically prefixes the plugin ID to route URLs.
|
||||
|
||||
## Calling Routes Externally
|
||||
|
||||
Routes are accessible at their full URL:
|
||||
|
||||
```bash
|
||||
# GET request
|
||||
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
|
||||
|
||||
# POST request
|
||||
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title": "Hello", "email": "user@example.com"}'
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Plugin routes don't have built-in authentication. For public endpoints, implement your own auth
|
||||
checks. For admin-only routes, the admin session middleware provides protection.
|
||||
</Aside>
|
||||
|
||||
## Route Context Reference
|
||||
|
||||
```typescript
|
||||
interface RouteContext<TInput = unknown> extends PluginContext {
|
||||
/** Validated input from request body or query params */
|
||||
input: TInput;
|
||||
|
||||
/** Original request object */
|
||||
request: Request;
|
||||
|
||||
/** Plugin metadata */
|
||||
plugin: { id: string; version: string };
|
||||
|
||||
/** Plugin storage collections */
|
||||
storage: Record<string, StorageCollection>;
|
||||
|
||||
/** Key-value store */
|
||||
kv: KVAccess;
|
||||
|
||||
/** Content access (if capability declared) */
|
||||
content?: ContentAccess;
|
||||
|
||||
/** Media access (if capability declared) */
|
||||
media?: MediaAccess;
|
||||
|
||||
/** HTTP client (if capability declared) */
|
||||
http?: HttpAccess;
|
||||
|
||||
/** Structured logger */
|
||||
log: LogAccess;
|
||||
}
|
||||
```
|
||||
146
docs/src/content/docs/plugins/block-kit.mdx
Normal file
@@ -0,0 +1,146 @@
|
||||
---
|
||||
title: Block Kit
|
||||
description: Declarative UI blocks for sandboxed plugin admin pages and widgets.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The host renders the blocks — no plugin-supplied JavaScript ever runs in the browser.
|
||||
|
||||
<Aside>
|
||||
Native plugins (declared in `astro.config.ts`) can still ship custom React components. Block Kit is the admin UI system for sandboxed plugins -- no plugin-supplied JavaScript ever runs in the browser.
|
||||
</Aside>
|
||||
|
||||
<Aside type="tip">
|
||||
Block Kit elements are also used for [Portable Text block editing fields](/plugins/creating-plugins/#portable-text-block-types). When a plugin declares `fields` on a block type, the editor renders a Block Kit form for editing block data (URL, title, parameters, etc.).
|
||||
</Aside>
|
||||
|
||||
## How it works
|
||||
|
||||
1. User navigates to a plugin's admin page
|
||||
2. The admin sends a `page_load` interaction to the plugin's admin route
|
||||
3. The plugin returns a `BlockResponse` containing an array of blocks
|
||||
4. The admin renders the blocks using the `BlockRenderer` component
|
||||
5. When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin
|
||||
6. The plugin returns new blocks, and the cycle repeats
|
||||
|
||||
```typescript
|
||||
// Plugin admin route handler
|
||||
routes: {
|
||||
admin: {
|
||||
handler: async (ctx, { request }) => {
|
||||
const interaction = await request.json();
|
||||
|
||||
if (interaction.type === "page_load") {
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "header", text: "My Plugin Settings" },
|
||||
{
|
||||
type: "form",
|
||||
block_id: "settings",
|
||||
fields: [
|
||||
{ type: "text_input", action_id: "api_url", label: "API URL" },
|
||||
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
|
||||
],
|
||||
submit: { label: "Save", action_id: "save" },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (interaction.type === "form_submit" && interaction.action_id === "save") {
|
||||
await ctx.kv.set("settings", interaction.values);
|
||||
return {
|
||||
blocks: [/* ... updated blocks ... */],
|
||||
toast: { message: "Settings saved", type: "success" },
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Block types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `header` | Large bold heading |
|
||||
| `section` | Text with optional accessory element |
|
||||
| `divider` | Horizontal rule |
|
||||
| `fields` | Two-column label/value grid |
|
||||
| `table` | Data table with formatting, sorting, pagination |
|
||||
| `actions` | Horizontal row of buttons and controls |
|
||||
| `stats` | Dashboard metric cards with trend indicators |
|
||||
| `form` | Input fields with conditional visibility and submit |
|
||||
| `image` | Block-level image with caption |
|
||||
| `context` | Small muted help text |
|
||||
| `columns` | 2-3 column layout with nested blocks |
|
||||
| `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons |
|
||||
| `accordion` | Collapsible section wrapping nested blocks |
|
||||
|
||||
## Element types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `button` | Action button with optional confirmation dialog |
|
||||
| `text_input` | Single-line or multiline text input |
|
||||
| `number_input` | Numeric input with min/max |
|
||||
| `select` | Dropdown select |
|
||||
| `toggle` | On/off switch |
|
||||
| `secret_input` | Masked input for API keys and tokens |
|
||||
|
||||
## Builder helpers
|
||||
|
||||
The `@emdash-cms/blocks` package exports builder helpers for cleaner code:
|
||||
|
||||
```typescript
|
||||
import { blocks, elements } from "@emdash-cms/blocks";
|
||||
|
||||
const { header, form, section, stats } = blocks;
|
||||
const { textInput, toggle, select, button } = elements;
|
||||
|
||||
return {
|
||||
blocks: [
|
||||
header("SEO Settings"),
|
||||
form({
|
||||
blockId: "settings",
|
||||
fields: [
|
||||
textInput("site_title", "Site Title", { initialValue: "My Site" }),
|
||||
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
|
||||
select("robots", "Default Robots", [
|
||||
{ label: "Index, Follow", value: "index,follow" },
|
||||
{ label: "No Index", value: "noindex,follow" },
|
||||
]),
|
||||
],
|
||||
submit: { label: "Save", actionId: "save" },
|
||||
}),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
## Conditional fields
|
||||
|
||||
Form fields can be conditionally shown based on other field values:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "toggle",
|
||||
"action_id": "auth_enabled",
|
||||
"label": "Enable Authentication"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "secret_input",
|
||||
"action_id": "api_key",
|
||||
"label": "API Key",
|
||||
"condition": { "field": "auth_enabled", "eq": true }
|
||||
}
|
||||
```
|
||||
|
||||
The `api_key` field only appears when `auth_enabled` is toggled on. Conditions are evaluated client-side with no round-trip.
|
||||
|
||||
## Try it
|
||||
|
||||
Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) to interactively build and test block layouts.
|
||||
479
docs/src/content/docs/plugins/creating-plugins.mdx
Normal file
@@ -0,0 +1,479 @@
|
||||
---
|
||||
title: Creating Plugins
|
||||
description: Build an EmDash plugin with hooks, storage, settings, and admin UI.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
This guide walks through building a complete EmDash plugin. You'll learn how to structure the code, define hooks and storage, and export admin UI components.
|
||||
|
||||
<Aside type="tip">
|
||||
**Which format should I use?** Plugins come in two formats: **standard** (works in both sandboxed and native mode) and **native** (native mode only). Use standard format unless you need React admin pages, Portable Text rendering components, or page fragment injection. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for a full comparison.
|
||||
</Aside>
|
||||
|
||||
This page covers native-format plugins with the full descriptor + definition structure. For standard-format plugins that can be published to the marketplace, see [Publishing Plugins](/plugins/publishing/).
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
Every native plugin has two parts that **run in different contexts**:
|
||||
|
||||
1. **Plugin descriptor** (`PluginDescriptor`) — returned by the factory function, tells EmDash how to load the plugin. **Runs at build time in Vite** (imported in `astro.config.mjs`). Must be side-effect-free and cannot use runtime APIs.
|
||||
2. **Plugin definition** (`definePlugin()`) — contains the runtime logic (hooks, routes, storage). **Runs at request time on the deployed server.** Has access to the full plugin context (`ctx`).
|
||||
|
||||
These must be in **separate entrypoints** because they execute in completely different environments:
|
||||
|
||||
```
|
||||
my-plugin/
|
||||
├── src/
|
||||
│ ├── descriptor.ts # Plugin descriptor (runs in Vite at build time)
|
||||
│ ├── index.ts # Plugin definition with definePlugin() (runs at deploy time)
|
||||
│ ├── admin.tsx # Admin UI exports (React components) — optional
|
||||
│ └── astro/ # Optional: Astro components for site-side rendering
|
||||
│ └── index.ts # Must export `blockComponents`
|
||||
├── package.json
|
||||
└── tsconfig.json
|
||||
```
|
||||
|
||||
## Creating the Plugin
|
||||
|
||||
### Descriptor (build time)
|
||||
|
||||
The descriptor tells EmDash where to find the plugin and what admin UI it provides. This file is imported in `astro.config.mjs` and runs in Vite.
|
||||
|
||||
```typescript title="src/descriptor.ts"
|
||||
import type { PluginDescriptor } from "emdash";
|
||||
|
||||
// Options your plugin accepts at registration time
|
||||
export interface MyPluginOptions {
|
||||
enabled?: boolean;
|
||||
maxItems?: number;
|
||||
}
|
||||
|
||||
export function myPlugin(options: MyPluginOptions = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
entrypoint: "@my-org/plugin-example",
|
||||
options,
|
||||
adminEntry: "@my-org/plugin-example/admin",
|
||||
componentsEntry: "@my-org/plugin-example/astro",
|
||||
adminPages: [{ path: "/settings", label: "Settings", icon: "settings" }],
|
||||
adminWidgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Definition (runtime)
|
||||
|
||||
The definition contains the runtime logic — hooks, routes, storage, and admin configuration. This file is loaded at request time on the deployed server.
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
import type { MyPluginOptions } from "./descriptor.js";
|
||||
|
||||
export function createPlugin(options: MyPluginOptions = {}) {
|
||||
const maxItems = options.maxItems ?? 100;
|
||||
|
||||
return definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
|
||||
// Declare required capabilities
|
||||
capabilities: ["content:read"],
|
||||
|
||||
// Plugin storage (document collections)
|
||||
storage: {
|
||||
items: {
|
||||
indexes: ["status", "createdAt", ["status", "createdAt"]],
|
||||
},
|
||||
},
|
||||
|
||||
// Admin UI configuration
|
||||
admin: {
|
||||
entry: "@my-org/plugin-example/admin",
|
||||
settingsSchema: {
|
||||
maxItems: {
|
||||
type: "number",
|
||||
label: "Maximum Items",
|
||||
description: "Limit stored items",
|
||||
default: maxItems,
|
||||
min: 1,
|
||||
max: 1000,
|
||||
},
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
default: options.enabled ?? true,
|
||||
},
|
||||
},
|
||||
pages: [{ path: "/settings", label: "Settings", icon: "settings" }],
|
||||
widgets: [{ id: "status", title: "Status", size: "half" }],
|
||||
},
|
||||
|
||||
// Hook handlers
|
||||
hooks: {
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
ctx.log.info("Plugin installed");
|
||||
},
|
||||
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const enabled = await ctx.kv.get<boolean>("settings:enabled");
|
||||
if (enabled === false) return;
|
||||
|
||||
ctx.log.info("Content saved", {
|
||||
collection: event.collection,
|
||||
id: event.content.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
// API routes
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (ctx) => {
|
||||
const count = await ctx.storage.items!.count();
|
||||
return { count, maxItems };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
## Plugin ID Rules
|
||||
|
||||
The `id` field must follow these rules:
|
||||
|
||||
- Lowercase alphanumeric characters and hyphens only
|
||||
- Either simple (`my-plugin`) or scoped (`@my-org/my-plugin`)
|
||||
- Unique across all installed plugins
|
||||
|
||||
```typescript
|
||||
// Valid IDs
|
||||
"seo";
|
||||
"audit-log";
|
||||
"@emdash-cms/plugin-forms";
|
||||
|
||||
// Invalid IDs
|
||||
"MyPlugin"; // No uppercase
|
||||
"my_plugin"; // No underscores
|
||||
"my.plugin"; // No dots
|
||||
```
|
||||
|
||||
## Version Format
|
||||
|
||||
Use semantic versioning:
|
||||
|
||||
```typescript
|
||||
version: "1.0.0"; // Valid
|
||||
version: "1.2.3-beta"; // Valid (prerelease)
|
||||
version: "1.0"; // Invalid (missing patch)
|
||||
```
|
||||
|
||||
## Package Exports
|
||||
|
||||
Configure `package.json` exports so EmDash can load each entrypoint. The descriptor and definition are separate exports because they run in different environments:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "@my-org/plugin-example",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./descriptor": {
|
||||
"types": "./dist/descriptor.d.ts",
|
||||
"import": "./dist/descriptor.js"
|
||||
},
|
||||
"./admin": {
|
||||
"types": "./dist/admin.d.ts",
|
||||
"import": "./dist/admin.js"
|
||||
},
|
||||
"./astro": {
|
||||
"types": "./dist/astro/index.d.ts",
|
||||
"import": "./dist/astro/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"peerDependencies": {
|
||||
"emdash": "^0.1.0",
|
||||
"react": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Context | Purpose |
|
||||
|--------|---------|---------|
|
||||
| `"."` | Server (runtime) | `createPlugin()` / `definePlugin()` — loaded by `entrypoint` at request time |
|
||||
| `"./descriptor"` | Vite (build time) | `PluginDescriptor` factory — imported in `astro.config.mjs` |
|
||||
| `"./admin"` | Browser | React components for admin pages/widgets |
|
||||
| `"./astro"` | Server (SSR) | Astro components for site-side block rendering |
|
||||
|
||||
Only include `./admin` and `./astro` exports if the plugin uses them.
|
||||
|
||||
<Aside type="tip">
|
||||
Keep `emdash` and `react` as peer dependencies. This prevents version conflicts when the plugin
|
||||
is installed in a site.
|
||||
</Aside>
|
||||
|
||||
## Complete Example: Audit Log Plugin
|
||||
|
||||
This example demonstrates storage, lifecycle hooks, content hooks, and API routes:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
interface AuditEntry {
|
||||
timestamp: string;
|
||||
action: "create" | "update" | "delete";
|
||||
collection: string;
|
||||
resourceId: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function createPlugin() {
|
||||
return definePlugin({
|
||||
id: "audit-log",
|
||||
version: "0.1.0",
|
||||
|
||||
storage: {
|
||||
entries: {
|
||||
indexes: [
|
||||
"timestamp",
|
||||
"action",
|
||||
"collection",
|
||||
["collection", "timestamp"],
|
||||
["action", "timestamp"],
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
retentionDays: {
|
||||
type: "number",
|
||||
label: "Retention (days)",
|
||||
description: "Days to keep entries. 0 = forever.",
|
||||
default: 90,
|
||||
min: 0,
|
||||
max: 365,
|
||||
},
|
||||
},
|
||||
pages: [{ path: "/history", label: "Audit History", icon: "history" }],
|
||||
widgets: [{ id: "recent-activity", title: "Recent Activity", size: "half" }],
|
||||
},
|
||||
|
||||
hooks: {
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
ctx.log.info("Audit log plugin installed");
|
||||
},
|
||||
|
||||
"content:afterSave": {
|
||||
priority: 200, // Run after other plugins
|
||||
timeout: 2000,
|
||||
handler: async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: isNew ? "create" : "update",
|
||||
collection,
|
||||
resourceId: content.id as string,
|
||||
};
|
||||
|
||||
const entryId = `${Date.now()}-${content.id}`;
|
||||
await ctx.storage.entries!.put(entryId, entry);
|
||||
|
||||
ctx.log.info(`Logged ${entry.action} on ${collection}/${content.id}`);
|
||||
},
|
||||
},
|
||||
|
||||
"content:afterDelete": {
|
||||
priority: 200,
|
||||
timeout: 1000,
|
||||
handler: async (event, ctx) => {
|
||||
const { id, collection } = event;
|
||||
|
||||
const entry: AuditEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "delete",
|
||||
collection,
|
||||
resourceId: id,
|
||||
};
|
||||
|
||||
const entryId = `${Date.now()}-${id}`;
|
||||
await ctx.storage.entries!.put(entryId, entry);
|
||||
|
||||
ctx.log.info(`Logged delete on ${collection}/${id}`);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
routes: {
|
||||
recent: {
|
||||
handler: async (ctx) => {
|
||||
const result = await ctx.storage.entries!.query({
|
||||
orderBy: { timestamp: "desc" },
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: result.items.map((item) => ({
|
||||
id: item.id,
|
||||
...(item.data as AuditEntry),
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
history: {
|
||||
handler: async (ctx) => {
|
||||
const url = new URL(ctx.request.url);
|
||||
const limit = parseInt(url.searchParams.get("limit") || "50", 10);
|
||||
const cursor = url.searchParams.get("cursor") || undefined;
|
||||
|
||||
const result = await ctx.storage.entries!.query({
|
||||
orderBy: { timestamp: "desc" },
|
||||
limit,
|
||||
cursor,
|
||||
});
|
||||
|
||||
return {
|
||||
entries: result.items.map((item) => ({
|
||||
id: item.id,
|
||||
...(item.data as AuditEntry),
|
||||
})),
|
||||
cursor: result.cursor,
|
||||
hasMore: result.hasMore,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default createPlugin;
|
||||
```
|
||||
|
||||
## Testing Plugins
|
||||
|
||||
Test plugins by creating a minimal Astro site with the plugin registered:
|
||||
|
||||
1. Create a test site with EmDash installed.
|
||||
|
||||
2. Register your plugin in `astro.config.mjs`:
|
||||
|
||||
```typescript
|
||||
import myPlugin from "../path/to/my-plugin/src";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [myPlugin()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
3. Run the dev server and trigger hooks by creating/updating content.
|
||||
|
||||
4. Check the console for `ctx.log` output and verify storage via API routes.
|
||||
|
||||
For unit tests, mock the `PluginContext` interface and call hook handlers directly.
|
||||
|
||||
## Portable Text Block Types
|
||||
|
||||
Plugins can add custom block types to the Portable Text editor. These appear in the editor's slash command menu and can be inserted into any `portableText` field.
|
||||
|
||||
### Declaring block types
|
||||
|
||||
In `createPlugin()`, declare blocks under `admin.portableTextBlocks`:
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
admin: {
|
||||
portableTextBlocks: [
|
||||
{
|
||||
type: "youtube",
|
||||
label: "YouTube Video",
|
||||
icon: "video", // Named icon: video, code, link, link-external
|
||||
placeholder: "Paste YouTube URL...",
|
||||
fields: [ // Block Kit fields for the editing UI
|
||||
{ type: "text_input", action_id: "id", label: "YouTube URL" },
|
||||
{ type: "text_input", action_id: "title", label: "Title" },
|
||||
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Each block type defines:
|
||||
|
||||
- **`type`** — Block type name (used in Portable Text `_type`)
|
||||
- **`label`** — Display name in the slash command menu
|
||||
- **`icon`** — Icon key (`video`, `code`, `link`, `link-external`). Falls back to a generic cube.
|
||||
- **`placeholder`** — Input placeholder text
|
||||
- **`fields`** — Block Kit form fields for editing. If omitted, a simple URL input is shown.
|
||||
|
||||
### Site-side rendering
|
||||
|
||||
To render your block types on the site, export Astro components from a `componentsEntry`:
|
||||
|
||||
```typescript title="src/astro/index.ts"
|
||||
import YouTube from "./YouTube.astro";
|
||||
import CodePen from "./CodePen.astro";
|
||||
|
||||
// This export name is required — the virtual module imports it
|
||||
export const blockComponents = {
|
||||
youtube: YouTube,
|
||||
codepen: CodePen,
|
||||
};
|
||||
```
|
||||
|
||||
Set `componentsEntry` in your plugin descriptor:
|
||||
|
||||
```typescript
|
||||
export function myPlugin(options = {}): PluginDescriptor {
|
||||
return {
|
||||
id: "my-plugin",
|
||||
entrypoint: "@my-org/my-plugin",
|
||||
componentsEntry: "@my-org/my-plugin/astro",
|
||||
// ...
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Plugin block components are automatically merged into `<PortableText>` — site authors don't need to import anything. User-provided components take precedence over plugin defaults.
|
||||
|
||||
<Aside type="tip">
|
||||
The embeds plugin (`@emdash-cms/plugin-embeds`) is a complete example of this pattern. It provides
|
||||
YouTube, Vimeo, Tweet, Bluesky, Mastodon, Gist, and Link Preview block types with both admin
|
||||
editing fields and site-side Astro rendering components.
|
||||
</Aside>
|
||||
|
||||
### Package exports
|
||||
|
||||
Add the `./astro` export to `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
|
||||
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
|
||||
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Hooks Reference](/plugins/hooks/) — All available hooks with signatures
|
||||
- [Storage API](/plugins/storage/) — Document collections and queries
|
||||
- [Settings](/plugins/settings/) — Settings schema and KV store
|
||||
- [Admin UI](/plugins/admin-ui/) — Pages and widgets
|
||||
- [API Routes](/plugins/api-routes/) — REST endpoints
|
||||
243
docs/src/content/docs/plugins/field-kit.mdx
Normal file
@@ -0,0 +1,243 @@
|
||||
---
|
||||
title: Field Kit
|
||||
description: Composable field widgets for json fields, configured through seed options.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's `json` field type stores arbitrary structured data, but the default editor is a single-line text input where you have to type raw JSON by hand. **Field Kit** is a first-party plugin that ships four composable widgets for `json` fields, configured entirely through seed `options` — no React required from site builders.
|
||||
|
||||
<Aside type="tip">
|
||||
Field Kit widgets store **clean JSON**: removing the plugin leaves valid data in the database. No shape mutation, no new columns, no migration.
|
||||
</Aside>
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm i @emdash-cms/plugin-field-kit
|
||||
```
|
||||
|
||||
Register the plugin in `astro.config.mjs`:
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash from "emdash";
|
||||
import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [fieldKitPlugin()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Then attach a widget to any `json` field by setting `widget` to `field-kit:<name>`:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "ingredients",
|
||||
"type": "json",
|
||||
"widget": "field-kit:list",
|
||||
"options": { "fields": [...] }
|
||||
}
|
||||
```
|
||||
|
||||
## Widgets
|
||||
|
||||
| Widget | Use for | Stored value |
|
||||
|--------|---------|--------------|
|
||||
| `object-form` | Inline form for flat JSON objects | `{ key: value, ... }` |
|
||||
| `list` | Ordered array editor with add / remove / reorder | `[{ ... }, ...]` |
|
||||
| `grid` | Rows × columns matrix | `{ rowKey: { colKey: value } }` |
|
||||
| `tags` | Free-form chip/tag input | `["tag1", "tag2"]` |
|
||||
|
||||
If a widget is missing its required `options` (e.g. `fields` for `object-form`/`list`, or `rows`/`columns` for `grid`), the editor renders an inline "Widget misconfigured" warning instead of a broken input — useful while iterating on seed schemas.
|
||||
|
||||
### object-form
|
||||
|
||||
Renders a group of typed sub-fields that store as a single JSON object. Good for fixed-shape structured data like nutrition facts or contact info.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "nutrition",
|
||||
"type": "json",
|
||||
"widget": "field-kit:object-form",
|
||||
"options": {
|
||||
"collapsed": false,
|
||||
"fields": [
|
||||
{ "key": "calories", "label": "Calories", "type": "number", "suffix": "kcal" },
|
||||
{ "key": "protein", "label": "Protein", "type": "number", "suffix": "g" },
|
||||
{ "key": "fat", "label": "Fat", "type": "number", "suffix": "g" },
|
||||
{ "key": "carbs", "label": "Carbs", "type": "number", "suffix": "g" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored value: `{ "calories": 250, "protein": 12.5, "fat": 8, "carbs": 30 }`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions — see [Sub-fields](#sub-fields). |
|
||||
| `collapsed` | `boolean` | `false` | Render the group collapsed by default. |
|
||||
| `helpText` | `string` | — | Help text shown below the widget. |
|
||||
|
||||
### list
|
||||
|
||||
An ordered array editor with add, remove, and reorder controls. Each row is a JSON object whose shape is defined by `fields`. The row header shows a summary rendered from a Mustache-style template.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "ingredients",
|
||||
"type": "json",
|
||||
"widget": "field-kit:list",
|
||||
"options": {
|
||||
"itemLabel": "Ingredient",
|
||||
"min": 1,
|
||||
"max": 50,
|
||||
"sortable": true,
|
||||
"summary": "{{name}} — {{amount}}",
|
||||
"fields": [
|
||||
{ "key": "name", "label": "Name", "type": "text", "required": true },
|
||||
{ "key": "amount", "label": "Amount", "type": "text" },
|
||||
{ "key": "optional", "label": "Optional", "type": "boolean" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored value:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "name": "Flour", "amount": "500g", "optional": false },
|
||||
{ "name": "Butter", "amount": "200g", "optional": false }
|
||||
]
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions for each row. |
|
||||
| `itemLabel` | `string` | `"Item"` | Singular label for a row (used in the "Add" button and fallback row titles). |
|
||||
| `min` | `number` | — | Minimum number of items. Below this, the remove button hides. |
|
||||
| `max` | `number` | — | Maximum number of items. At this count, the add button hides. |
|
||||
| `sortable` | `boolean` | `true` | Show up/down reorder buttons. |
|
||||
| `summary` | `string` | — | Mustache template rendered as the collapsed-row title. See [Summary templates](#summary-templates). |
|
||||
| `helpText` | `string` | — | Help text shown below the widget. |
|
||||
|
||||
### grid
|
||||
|
||||
A two-dimensional matrix of rows × columns. Each cell can be a toggle, text input, number input, or select. Useful for matrices like seasonal availability, price tables, or feature comparisons.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "availability",
|
||||
"type": "json",
|
||||
"widget": "field-kit:grid",
|
||||
"options": {
|
||||
"cell": "toggle",
|
||||
"rows": [
|
||||
{ "key": "berries", "label": "Berries" },
|
||||
{ "key": "stoneFruit", "label": "Stone fruit" },
|
||||
{ "key": "citrus", "label": "Citrus" }
|
||||
],
|
||||
"columns": [
|
||||
{ "key": "spring", "label": "Spring" },
|
||||
{ "key": "summer", "label": "Summer" },
|
||||
{ "key": "autumn", "label": "Autumn" },
|
||||
{ "key": "winter", "label": "Winter" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored value:
|
||||
|
||||
```json
|
||||
{
|
||||
"berries": { "spring": false, "summer": true, "autumn": false, "winter": false },
|
||||
"stoneFruit": { "spring": false, "summer": true, "autumn": true, "winter": false },
|
||||
"citrus": { "spring": false, "summer": false, "autumn": true, "winter": true }
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `rows` | `GridAxisDef[]` | _(required)_ | Row definitions: `{ key, label, image? }`. |
|
||||
| `columns` | `GridAxisDef[]` | _(required)_ | Column definitions: `{ key, label, image? }`. |
|
||||
| `cell` | `"toggle"` \| `"text"` \| `"number"` \| `"select"` | `"toggle"` | Cell input type, applied uniformly to every cell. |
|
||||
| `cellOptions` | `string[]` \| `Array<{ label, value }>` | `[]` | Required when `cell` is `"select"`. |
|
||||
| `helpText` | `string` | — | Help text shown below the widget. |
|
||||
|
||||
### tags
|
||||
|
||||
A chip-style input for arrays of strings. Supports a fixed `suggestions` list, free-form custom values (toggleable), case transforms, and an optional `max`.
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "keywords",
|
||||
"type": "json",
|
||||
"widget": "field-kit:tags",
|
||||
"options": {
|
||||
"placeholder": "Add a keyword…",
|
||||
"max": 10,
|
||||
"transform": "lowercase",
|
||||
"allowCustom": true,
|
||||
"suggestions": ["vegan", "vegetarian", "gluten-free", "dairy-free", "nut-free"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Stored value: `["vegan", "gluten-free"]`.
|
||||
|
||||
Press <kbd>Enter</kbd> or `,` to commit a tag. <kbd>Backspace</kbd> on an empty input removes the last tag. Duplicate tags are silently ignored.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `placeholder` | `string` | `"Add..."` | Input placeholder shown when no tags are present. |
|
||||
| `max` | `number` | — | Maximum number of tags. The input hides at the limit. |
|
||||
| `suggestions` | `string[]` | `[]` | Autocomplete suggestions surfaced via a `<datalist>`. |
|
||||
| `allowCustom` | `boolean` | `true` | When `false`, only values from `suggestions` can be added. |
|
||||
| `transform` | `"none"` \| `"lowercase"` \| `"uppercase"` \| `"trim"` | `"none"` | Normalize tags as they're added. |
|
||||
| `helpText` | `string` | — | Help text shown below the widget. |
|
||||
|
||||
## Sub-fields
|
||||
|
||||
`object-form` and `list` accept an `options.fields` array of typed sub-field definitions. Each entry has a `key` (the JSON object key it writes to), a `label`, a `type`, and type-specific extras.
|
||||
|
||||
| Sub-field type | Renders as | Notable extras |
|
||||
|----------------|------------|----------------|
|
||||
| `text` | Single-line input | `placeholder` |
|
||||
| `textarea` | Multi-line input | `rows` (default `3`), `placeholder` |
|
||||
| `number` | Numeric input | `min`, `max`, `step`, `prefix`, `suffix`, `placeholder` |
|
||||
| `boolean` | Toggle switch | — |
|
||||
| `select` | Dropdown | `options: string[] \| Array<{ label, value }>`, `placeholder` |
|
||||
| `date` | Date input | — |
|
||||
| `color` | Native color picker paired with a hex text input | — |
|
||||
| `url` | URL input (HTML5 `type="url"`) | `placeholder` |
|
||||
|
||||
Common props on every sub-field: `required`, `helpText`, `defaultValue`.
|
||||
|
||||
## Summary templates
|
||||
|
||||
The `list` widget renders each collapsed row using a Mustache-style template in `options.summary`. `{{key}}` is replaced with the row's value for that key (coerced to a string). Falsy values fall back to `"{itemLabel} {n}"`.
|
||||
|
||||
```
|
||||
"summary": "{{name}} — {{amount}}"
|
||||
```
|
||||
|
||||
Renders rows like `Flour — 500g`. The template is plain string substitution — no HTML, no nested expressions.
|
||||
|
||||
## Data durability
|
||||
|
||||
Field Kit widgets store plain JSON in the field's existing column. There are no plugin-specific tables, no foreign keys, no schema mutation. If you remove `@emdash-cms/plugin-field-kit` from your config, the data stays valid — only the editing UI changes back to the default `json` text input.
|
||||
|
||||
This applies even when you change the widget shape: unknown keys on stored objects are preserved on the next write, so you can evolve a schema without losing data captured under an older field set.
|
||||
|
||||
## See also
|
||||
|
||||
- [Plugin Overview](/plugins/overview/) — how EmDash plugins work.
|
||||
- [Creating Plugins](/plugins/creating-plugins/) — write your own field widgets if Field Kit doesn't fit.
|
||||
- [Discussion #571](https://github.com/emdash-cms/emdash/discussions/571) — the proposal that led to this plugin.
|
||||
580
docs/src/content/docs/plugins/hooks.mdx
Normal file
@@ -0,0 +1,580 @@
|
||||
---
|
||||
title: Plugin Hooks
|
||||
description: Hook into content, media, and plugin lifecycle events.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Hooks let plugins run code in response to events. All hooks receive an event object and the plugin context. Hooks are declared at plugin definition time, not registered dynamically at runtime.
|
||||
|
||||
## Hook Signature
|
||||
|
||||
Every hook handler receives two arguments:
|
||||
|
||||
```typescript
|
||||
async (event: EventType, ctx: PluginContext) => ReturnType;
|
||||
```
|
||||
|
||||
- `event` — Data about the event (content being saved, media uploaded, etc.)
|
||||
- `ctx` — The [plugin context](/plugins/overview/#plugin-context) with storage, KV, logging, and capability-gated APIs
|
||||
|
||||
## Hook Configuration
|
||||
|
||||
Hooks can be declared as a simple handler or with full configuration:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Simple">
|
||||
```typescript
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Content saved");
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Full Config">
|
||||
```typescript
|
||||
hooks: {
|
||||
"content:afterSave": {
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
dependencies: ["audit-log"],
|
||||
errorPolicy: "continue",
|
||||
handler: async (event, ctx) => {
|
||||
ctx.log.info("Content saved");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | ----------------------- | --------- | ------------------------------------------ |
|
||||
| `priority` | `number` | `100` | Execution order. Lower numbers run first. |
|
||||
| `timeout` | `number` | `5000` | Maximum execution time in milliseconds. |
|
||||
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run before this hook. |
|
||||
| `errorPolicy` | `"abort" \| "continue"` | `"abort"` | Whether to stop the pipeline on error. |
|
||||
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider. Used for `email:deliver` and `comment:moderate`. |
|
||||
| `handler` | `function` | — | The hook handler function. Required. |
|
||||
|
||||
<Aside type="tip">
|
||||
Use `errorPolicy: "continue"` for non-critical hooks like notifications. Use `"abort"` (the
|
||||
default) when the hook's result is essential.
|
||||
</Aside>
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Lifecycle hooks run during plugin installation, activation, and deactivation.
|
||||
|
||||
### `plugin:install`
|
||||
|
||||
Runs once when the plugin is first added to a site.
|
||||
|
||||
```typescript
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
ctx.log.info("Installing plugin...");
|
||||
|
||||
// Seed default data
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.storage.items!.put("default", { name: "Default Item" });
|
||||
}
|
||||
```
|
||||
|
||||
**Event:** `{}`
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `plugin:activate`
|
||||
|
||||
Runs when the plugin is enabled (after install or when re-enabled).
|
||||
|
||||
```typescript
|
||||
"plugin:activate": async (_event, ctx) => {
|
||||
ctx.log.info("Plugin activated");
|
||||
}
|
||||
```
|
||||
|
||||
**Event:** `{}`
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `plugin:deactivate`
|
||||
|
||||
Runs when the plugin is disabled (but not removed).
|
||||
|
||||
```typescript
|
||||
"plugin:deactivate": async (_event, ctx) => {
|
||||
ctx.log.info("Plugin deactivated");
|
||||
// Release resources, pause background work
|
||||
}
|
||||
```
|
||||
|
||||
**Event:** `{}`
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `plugin:uninstall`
|
||||
|
||||
Runs when the plugin is removed from a site.
|
||||
|
||||
```typescript
|
||||
"plugin:uninstall": async (event, ctx) => {
|
||||
ctx.log.info("Uninstalling plugin...");
|
||||
|
||||
if (event.deleteData) {
|
||||
// User opted to delete plugin data
|
||||
const result = await ctx.storage.items!.query({ limit: 1000 });
|
||||
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Event:** `{ deleteData: boolean }`
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
<Aside type="caution">
|
||||
Be conservative in `plugin:uninstall`. Default to preserving data—users may reinstall. Only delete
|
||||
when `event.deleteData` is `true`.
|
||||
</Aside>
|
||||
|
||||
## Content Hooks
|
||||
|
||||
Content hooks run during create, update, and delete operations.
|
||||
|
||||
### `content:beforeSave`
|
||||
|
||||
Runs before content is saved. Return modified content or `void` to keep it unchanged. Throw to cancel the save.
|
||||
|
||||
```typescript
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
// Validate
|
||||
if (collection === "posts" && !content.title) {
|
||||
throw new Error("Posts require a title");
|
||||
}
|
||||
|
||||
// Transform
|
||||
if (content.slug) {
|
||||
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
content: Record<string, unknown>; // Content data being saved
|
||||
collection: string; // Collection name
|
||||
isNew: boolean; // True if creating, false if updating
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<Record<string, unknown> | void>`
|
||||
|
||||
### `content:afterSave`
|
||||
|
||||
Runs after content is successfully saved. Use for side effects like notifications, logging, or syncing to external systems.
|
||||
|
||||
```typescript
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
|
||||
|
||||
// Trigger external sync
|
||||
if (ctx.http) {
|
||||
await ctx.http.fetch("https://api.example.com/webhook", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event: "content:save", id: content.id })
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
content: Record<string, unknown>; // Saved content (includes id, timestamps)
|
||||
collection: string;
|
||||
isNew: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `content:beforeDelete`
|
||||
|
||||
Runs before content is deleted. Return `false` to cancel the deletion, `true` or `void` to allow it.
|
||||
|
||||
```typescript
|
||||
"content:beforeDelete": async (event, ctx) => {
|
||||
const { id, collection } = event;
|
||||
|
||||
// Prevent deletion of protected content
|
||||
if (collection === "pages" && id === "home") {
|
||||
ctx.log.warn("Cannot delete home page");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string; // Content ID being deleted
|
||||
collection: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<boolean | void>`
|
||||
|
||||
### `content:afterDelete`
|
||||
|
||||
Runs after content is successfully deleted.
|
||||
|
||||
```typescript
|
||||
"content:afterDelete": async (event, ctx) => {
|
||||
const { id, collection } = event;
|
||||
|
||||
ctx.log.info(`Deleted ${collection}/${id}`);
|
||||
|
||||
// Clean up related plugin data
|
||||
await ctx.storage.cache!.delete(`${collection}:${id}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: string;
|
||||
collection: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterPublish`
|
||||
|
||||
Runs after content is published (promoted from draft to live). Use for side effects like cache invalidation, notifications, or syncing to external systems.
|
||||
Requires `content:read` capability.
|
||||
|
||||
```typescript
|
||||
"content:afterPublish": async (event, ctx) => {
|
||||
const { content, collection } = event;
|
||||
|
||||
ctx.log.info(`Published ${collection}/${content.id}`);
|
||||
|
||||
// Notify external system
|
||||
if (ctx.http) {
|
||||
await ctx.http.fetch("https://api.example.com/webhook", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ event: "content:publish", id: content.id })
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
content: Record<string, unknown>; // Published content (includes id, timestamps)
|
||||
collection: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
### `content:afterUnpublish`
|
||||
|
||||
Runs after content is unpublished (reverted from live to draft). Use for side effects like cache invalidation or notifying external systems.
|
||||
Requires `content:read` capability.
|
||||
|
||||
```typescript
|
||||
"content:afterUnpublish": async (event, ctx) => {
|
||||
const { content, collection } = event;
|
||||
|
||||
ctx.log.info(`Unpublished ${collection}/${content.id}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
content: Record<string, unknown>; // Unpublished content
|
||||
collection: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
## Media Hooks
|
||||
|
||||
Media hooks run during file uploads.
|
||||
|
||||
### `media:beforeUpload`
|
||||
|
||||
Runs before a file is uploaded. Return modified file info or `void` to keep it unchanged. Throw to cancel the upload.
|
||||
|
||||
```typescript
|
||||
"media:beforeUpload": async (event, ctx) => {
|
||||
const { file } = event;
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
throw new Error("Only images are allowed");
|
||||
}
|
||||
|
||||
// Validate file size (10MB max)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
throw new Error("File too large");
|
||||
}
|
||||
|
||||
// Rename file
|
||||
return {
|
||||
...file,
|
||||
name: `${Date.now()}-${file.name}`
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
file: {
|
||||
name: string; // Original filename
|
||||
type: string; // MIME type
|
||||
size: number; // Size in bytes
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<{ name: string; type: string; size: number } | void>`
|
||||
|
||||
### `media:afterUpload`
|
||||
|
||||
Runs after a file is successfully uploaded.
|
||||
|
||||
```typescript
|
||||
"media:afterUpload": async (event, ctx) => {
|
||||
const { media } = event;
|
||||
|
||||
ctx.log.info(`Uploaded ${media.filename}`, {
|
||||
id: media.id,
|
||||
size: media.size,
|
||||
mimeType: media.mimeType
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `Promise<void>`
|
||||
|
||||
## Hook Execution Order
|
||||
|
||||
Hooks run in this order:
|
||||
|
||||
1. Hooks with lower `priority` values run first
|
||||
2. For equal priorities, hooks run in plugin registration order
|
||||
3. Hooks with `dependencies` wait for those plugins to complete
|
||||
|
||||
```typescript
|
||||
// Plugin A
|
||||
"content:afterSave": {
|
||||
priority: 50, // Runs first
|
||||
handler: async () => {}
|
||||
}
|
||||
|
||||
// Plugin B
|
||||
"content:afterSave": {
|
||||
priority: 100, // Runs second (default priority)
|
||||
handler: async () => {}
|
||||
}
|
||||
|
||||
// Plugin C
|
||||
"content:afterSave": {
|
||||
priority: 200,
|
||||
dependencies: ["plugin-a"], // Runs after A, even if priority was lower
|
||||
handler: async () => {}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
When a hook throws or times out:
|
||||
|
||||
- **`errorPolicy: "abort"`** — The entire pipeline stops. The original operation may fail.
|
||||
- **`errorPolicy: "continue"`** — The error is logged, and remaining hooks still run.
|
||||
|
||||
```typescript
|
||||
"content:afterSave": {
|
||||
timeout: 5000,
|
||||
errorPolicy: "continue", // Don't fail the save if this hook fails
|
||||
handler: async (event, ctx) => {
|
||||
// External API call that might fail
|
||||
await ctx.http!.fetch("https://unreliable-api.com/notify");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Use `errorPolicy: "continue"` for non-critical operations like analytics, notifications, or
|
||||
external syncs. The content save succeeds even if the hook fails.
|
||||
</Aside>
|
||||
|
||||
## Timeouts
|
||||
|
||||
Hooks have a default timeout of 5000ms (5 seconds). Increase it for operations that may take longer:
|
||||
|
||||
```typescript
|
||||
"content:afterSave": {
|
||||
timeout: 30000, // 30 seconds
|
||||
handler: async (event, ctx) => {
|
||||
// Long-running operation
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
In sandboxed mode on Cloudflare, resource limits are enforced at the isolate level. Long-running
|
||||
hooks may be terminated regardless of the configured timeout.
|
||||
</Aside>
|
||||
|
||||
## Public Page Hooks
|
||||
|
||||
Public page hooks let plugins contribute to the `<head>` and `<body>` of rendered pages. Templates opt in using the `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components from `emdash/ui`.
|
||||
|
||||
### `page:metadata`
|
||||
|
||||
Contributes typed metadata to `<head>` — meta tags, OpenGraph properties, canonical/alternate links, and JSON-LD structured data. Works in both native and sandboxed modes.
|
||||
|
||||
Core validates, deduplicates, and renders the contributions. Plugins return structured data, never raw HTML.
|
||||
|
||||
```typescript
|
||||
"page:metadata": async (event, ctx) => {
|
||||
if (event.page.kind !== "content") return null;
|
||||
|
||||
return {
|
||||
kind: "jsonld",
|
||||
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
|
||||
graph: {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BlogPosting",
|
||||
headline: event.page.pageTitle ?? event.page.title,
|
||||
description: event.page.description,
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Event:**
|
||||
|
||||
```typescript
|
||||
{
|
||||
page: {
|
||||
url: string;
|
||||
path: string;
|
||||
locale: string | null;
|
||||
kind: "content" | "custom";
|
||||
pageType: string;
|
||||
title: string | null;
|
||||
pageTitle?: string | null;
|
||||
description: string | null;
|
||||
canonical: string | null;
|
||||
image: string | null;
|
||||
content?: { collection: string; id: string; slug: string | null };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `PageMetadataContribution | PageMetadataContribution[] | null`
|
||||
|
||||
**Contribution types:**
|
||||
|
||||
| Kind | Renders | Dedupe key |
|
||||
| ---------- | -------------------------------------------------- | ----------------------- |
|
||||
| `meta` | `<meta name="..." content="...">` | `key` or `name` |
|
||||
| `property` | `<meta property="..." content="...">` | `key` or `property` |
|
||||
| `link` | `<link rel="canonical\|alternate" href="...">` | canonical: singleton; alternate: `key` or `hreflang` |
|
||||
| `jsonld` | `<script type="application/ld+json">` | `id` (if present) |
|
||||
|
||||
First contribution wins for any dedupe key. Link hrefs must be HTTP or HTTPS.
|
||||
|
||||
### `page:fragments`
|
||||
|
||||
Contributes raw HTML, scripts, or markup to page insertion points. **Native plugins only** — sandboxed plugins cannot use this hook.
|
||||
|
||||
```typescript
|
||||
"page:fragments": async (event, ctx) => {
|
||||
return {
|
||||
kind: "external-script",
|
||||
placement: "head",
|
||||
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
|
||||
async: true,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Returns:** `PageFragmentContribution | PageFragmentContribution[] | null`
|
||||
|
||||
Placements: `"head"`, `"body:start"`, `"body:end"`. Templates that omit a component for a placement silently ignore contributions targeting it.
|
||||
|
||||
<Aside type="caution">
|
||||
`page:fragments` is native-only because its output runs as first-party code in the browser.
|
||||
Worker Loader isolation does not extend to browser-executed scripts. Use `page:metadata` for
|
||||
sandbox-safe contributions.
|
||||
</Aside>
|
||||
|
||||
## Hooks Reference
|
||||
|
||||
| Hook | Trigger | Return | Exclusive |
|
||||
| ----------------------- | ------------------------------ | ------------------------------ | --------- |
|
||||
| `plugin:install` | First plugin installation | `void` | No |
|
||||
| `plugin:activate` | Plugin enabled | `void` | No |
|
||||
| `plugin:deactivate` | Plugin disabled | `void` | No |
|
||||
| `plugin:uninstall` | Plugin removed | `void` | No |
|
||||
| `content:beforeSave` | Before content save | Modified content or `void` | No |
|
||||
| `content:afterSave` | After content save | `void` | No |
|
||||
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow | No |
|
||||
| `content:afterDelete` | After content delete | `void` | No |
|
||||
| `content:afterPublish` | After content publish | `void` | No |
|
||||
| `content:afterUnpublish`| After content unpublish | `void` | No |
|
||||
| `media:beforeUpload` | Before file upload | Modified file info or `void` | No |
|
||||
| `media:afterUpload` | After file upload | `void` | No |
|
||||
| `cron` | Scheduled task fires | `void` | No |
|
||||
| `email:beforeSend` | Before email delivery | Modified message, `false`, or `void` | No |
|
||||
| `email:deliver` | Deliver email via transport | `void` | Yes |
|
||||
| `email:afterSend` | After email delivery | `void` | No |
|
||||
| `comment:beforeCreate` | Before comment stored | Modified event, `false`, or `void` | No |
|
||||
| `comment:moderate` | Decide comment status | `{ status, reason? }` | Yes |
|
||||
| `comment:afterCreate` | After comment stored | `void` | No |
|
||||
| `comment:afterModerate` | Admin changes comment status | `void` | No |
|
||||
| `page:metadata` | Page render | Contributions or `null` | No |
|
||||
| `page:fragments` | Page render (native only) | Contributions or `null` | No |
|
||||
|
||||
See the [Hook Reference](/reference/hooks/) for complete event types and handler signatures.
|
||||
140
docs/src/content/docs/plugins/installing.mdx
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Installing Plugins
|
||||
description: Install plugins from the EmDash Marketplace or add them from code.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash plugins can be installed in two ways: from the marketplace via the admin dashboard, or added directly in your Astro configuration. Marketplace plugins run in an isolated sandbox; config-based plugins run in-process.
|
||||
|
||||
## From the Marketplace
|
||||
|
||||
The admin dashboard includes a marketplace browser where you can search, install, and manage plugins.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
To install marketplace plugins, your site needs:
|
||||
|
||||
1. **Sandbox runner configured** — Marketplace plugins run in isolated V8 workers, which requires the sandbox runtime:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { emdash } from "emdash/astro";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
marketplace: "https://marketplace.emdashcms.com",
|
||||
sandboxRunner: true,
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
2. **Admin access** — Only administrators can install or remove plugins.
|
||||
|
||||
### Browse and Install
|
||||
|
||||
<Steps>
|
||||
1. Open the admin panel and navigate to **Plugins > Marketplace**
|
||||
2. Browse or search for a plugin
|
||||
3. Click the plugin card to see its detail page — README, screenshots, capabilities, and security audit results
|
||||
4. Click **Install**
|
||||
5. Review the capability consent dialog — this shows what the plugin will be able to access
|
||||
6. Confirm the installation
|
||||
</Steps>
|
||||
|
||||
The plugin will be downloaded, stored in your site's R2 bucket, and loaded into the sandbox runner. It's active immediately.
|
||||
|
||||
### Capability Consent
|
||||
|
||||
Before installation, you'll see a dialog listing what the plugin needs access to:
|
||||
|
||||
| Capability | What it means |
|
||||
| ---------- | ------------- |
|
||||
| `content:read` | Read your content |
|
||||
| `content:write` | Create, update, and delete content |
|
||||
| `media:read` | Access your media library |
|
||||
| `media:write` | Upload and manage media |
|
||||
| `network:request` | Make network requests to specific hosts |
|
||||
|
||||
<Aside type="caution">
|
||||
Only install plugins from authors you trust. The capability system limits what a sandboxed plugin can access, but a plugin with `content:write` can modify any content on your site.
|
||||
</Aside>
|
||||
|
||||
### Security Audit
|
||||
|
||||
Every plugin version in the marketplace has been through an automated security audit. The audit verdict appears on the plugin card:
|
||||
|
||||
- **Pass** — No issues found
|
||||
- **Warn** — Minor concerns flagged (review the findings)
|
||||
- **Fail** — Significant security issues detected
|
||||
|
||||
You can view the full audit report on the plugin's detail page, including individual findings and their severity.
|
||||
|
||||
### Updates
|
||||
|
||||
When a newer version of an installed plugin is available:
|
||||
|
||||
1. Go to **Plugins** in the admin panel
|
||||
2. Marketplace plugins show an **Update available** badge
|
||||
3. Click **Update** to see the changelog and any capability changes
|
||||
4. If the new version requires additional capabilities, you'll see a diff and need to approve
|
||||
5. Confirm to update
|
||||
|
||||
<Aside type="note">
|
||||
Updates that add new capabilities require explicit approval. If a plugin that previously only read content now wants to make network requests, you'll see the new capability highlighted before confirming.
|
||||
</Aside>
|
||||
|
||||
### Uninstalling
|
||||
|
||||
1. Go to **Plugins** in the admin panel
|
||||
2. Click the marketplace plugin you want to remove
|
||||
3. Click **Uninstall**
|
||||
4. Choose whether to keep or delete the plugin's stored data
|
||||
5. Confirm
|
||||
|
||||
The plugin's sandbox code is removed from your R2 bucket and it stops running immediately.
|
||||
|
||||
## From Configuration
|
||||
|
||||
For native plugins (your own code, or packages you install via npm), add them directly to your Astro config:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import { emdash } from "emdash/astro";
|
||||
import seoPlugin from "@emdash-cms/plugin-seo";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [
|
||||
seoPlugin({ generateSitemap: true }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Native plugins:
|
||||
|
||||
- Run in-process (not sandboxed)
|
||||
- Have full access to Node.js APIs
|
||||
- Are loaded at build time and on every server start
|
||||
- Cannot be installed or removed from the admin UI
|
||||
|
||||
<Aside type="tip">
|
||||
Use native plugins only when you need features that require build-time integration: React admin pages, Portable Text rendering components, or page fragment injection. For everything else, prefer sandboxed plugins -- they can be installed, updated, and removed without code changes or redeployments.
|
||||
</Aside>
|
||||
|
||||
## Marketplace vs. Config: When to Use Which
|
||||
|
||||
| | Marketplace (sandboxed) | Config (native) |
|
||||
| --- | --- | --- |
|
||||
| **Install method** | One-click in admin UI | Code change + `npm install` + deploy |
|
||||
| **Execution** | Isolated V8 isolate | In-process |
|
||||
| **Capabilities** | Enforced at runtime | Advisory only |
|
||||
| **Node.js APIs** | Not available | Full access |
|
||||
| **React admin pages** | No (Block Kit instead) | Yes |
|
||||
| **PT rendering components** | No | Yes |
|
||||
| **Updates** | One-click in admin | Version bump + deploy |
|
||||
| **Best for** | Most plugins | Plugins needing build-time integration |
|
||||
224
docs/src/content/docs/plugins/overview.mdx
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
title: Plugin System Overview
|
||||
description: Extend EmDash with plugins that add hooks, storage, settings, and admin UI.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash's plugin system lets you extend the CMS without modifying core code. Plugins can hook into content lifecycle events, store their own data, expose settings to administrators, and add custom UI to the admin panel.
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
EmDash plugins come in two flavors: **sandboxed** and **native**. Sandboxed plugins run in isolated V8 workers and can be installed from the marketplace with one click. Native plugins run in-process and are configured in code.
|
||||
|
||||
**Prefer sandboxed plugins.** They can be installed, updated, and removed from the admin UI without touching code or redeploying. Only use native plugins when you need features that require build-time integration (React admin pages, Portable Text rendering components, or page fragment injection).
|
||||
|
||||
**Key principles:**
|
||||
|
||||
- **Sandbox-first** — Design for the sandbox; use native mode only when you need to
|
||||
- **Declarative** — Hooks, storage, and routes are declared at definition time, not registered dynamically
|
||||
- **Type-safe** — Full TypeScript support with typed context objects
|
||||
- **Capability-based** — Plugins declare what they need; the sandbox enforces it
|
||||
|
||||
<Aside type="note">
|
||||
EmDash plugins are not Astro integrations. They're passed to the EmDash integration in your
|
||||
Astro config. A plugin that needs both can ship as an Astro integration that also registers
|
||||
EmDash hooks.
|
||||
</Aside>
|
||||
|
||||
## What Plugins Can Do
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Hook into events" icon="rocket">
|
||||
Run code before or after content saves, media uploads, and plugin lifecycle events.
|
||||
</Card>
|
||||
<Card title="Store data" icon="document">
|
||||
Persist plugin-specific data in indexed collections without writing database migrations.
|
||||
</Card>
|
||||
<Card title="Expose settings" icon="setting">
|
||||
Declare a settings schema and get an auto-generated admin UI for configuration.
|
||||
</Card>
|
||||
<Card title="Add admin pages" icon="laptop">
|
||||
Create custom admin pages and dashboard widgets with React components.
|
||||
</Card>
|
||||
<Card title="Create API routes" icon="external">
|
||||
Expose endpoints for your plugin's admin UI or external integrations.
|
||||
</Card>
|
||||
<Card title="Make HTTP requests" icon="external">
|
||||
Call external APIs with declared host restrictions for security.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
Every plugin is created with `definePlugin()`:
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
|
||||
// What APIs the plugin needs access to
|
||||
capabilities: ["content:read", "network:request"],
|
||||
|
||||
// Hosts the plugin can make HTTP requests to
|
||||
allowedHosts: ["api.example.com"],
|
||||
|
||||
// Persistent storage collections
|
||||
storage: {
|
||||
entries: {
|
||||
indexes: ["userId", "createdAt"],
|
||||
},
|
||||
},
|
||||
|
||||
// Event handlers
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Content saved", { id: event.content.id });
|
||||
},
|
||||
},
|
||||
|
||||
// REST API endpoints
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (ctx) => ({ ok: true }),
|
||||
},
|
||||
},
|
||||
|
||||
// Admin UI configuration
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
apiKey: { type: "secret", label: "API Key" },
|
||||
},
|
||||
pages: [{ path: "/dashboard", label: "Dashboard" }],
|
||||
widgets: [{ id: "status", size: "half" }],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Plugin Context
|
||||
|
||||
Every hook and route handler receives a `PluginContext` object with access to:
|
||||
|
||||
| Property | Description | Availability |
|
||||
| ------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| `ctx.storage` | Plugin's document collections | Always (if declared) |
|
||||
| `ctx.kv` | Key-value store for settings and state | Always |
|
||||
| `ctx.content` | Read/write site content | With `content:read` or `content:write` |
|
||||
| `ctx.media` | Read/write media files | With `media:read` or `media:write` |
|
||||
| `ctx.http` | HTTP client for external requests | With `network:request` |
|
||||
| `ctx.log` | Structured logger (debug, info, warn, error) | Always |
|
||||
| `ctx.plugin` | Plugin metadata (id, version) | Always |
|
||||
| `ctx.site` | Site info: `name`, `url`, `locale` | Always |
|
||||
| `ctx.url()` | Generate absolute URLs from paths | Always |
|
||||
| `ctx.users` | Read user info: `get()`, `getByEmail()`, `list()` | With `users:read` |
|
||||
| `ctx.cron` | Schedule tasks: `schedule()`, `cancel()`, `list()` | Always |
|
||||
| `ctx.email` | Send email: `send()` | With `email:send` + provider configured |
|
||||
|
||||
The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability.
|
||||
|
||||
## Capabilities
|
||||
|
||||
Capabilities determine what APIs are available in the plugin context. Capability
|
||||
names follow the formula `<resource>[.<sub-resource>]:<verb>[:<qualifier>]` —
|
||||
resource first, verb second.
|
||||
|
||||
| Capability | Grants Access To |
|
||||
| ----------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
|
||||
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
|
||||
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
|
||||
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
|
||||
| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) |
|
||||
| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) |
|
||||
| `users:read` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
|
||||
| `email:send` | `ctx.email.send()` (requires a provider plugin) |
|
||||
| `hooks.email-transport:register` | Register `email:deliver` exclusive hook (transport provider) |
|
||||
| `hooks.email-events:register` | Register `email:beforeSend` / `email:afterSend` hooks |
|
||||
| `hooks.page-fragments:register` | Register `page:fragments` hook (inject scripts/styles into pages) |
|
||||
|
||||
<Aside type="tip">
|
||||
`content:write` implies `content:read`. Same for media. Declare only what you
|
||||
need.
|
||||
</Aside>
|
||||
|
||||
<Aside type="caution" title="Renamed in this minor">
|
||||
The capability names were unified in this minor. Old names are still accepted
|
||||
with `@deprecated` warnings and will be removed in the next minor:
|
||||
|
||||
| Old | New |
|
||||
| -------------------- | ---------------------------------- |
|
||||
| `read:content` | `content:read` |
|
||||
| `write:content` | `content:write` |
|
||||
| `read:media` | `media:read` |
|
||||
| `write:media` | `media:write` |
|
||||
| `read:users` | `users:read` |
|
||||
| `network:fetch` | `network:request` |
|
||||
| `network:fetch:any` | `network:request:unrestricted` |
|
||||
| `email:provide` | `hooks.email-transport:register` |
|
||||
| `email:intercept` | `hooks.email-events:register` |
|
||||
| `page:inject` | `hooks.page-fragments:register` |
|
||||
|
||||
`emdash plugin bundle` and `emdash plugin validate` warn for each
|
||||
deprecated name. `emdash plugin publish` refuses manifests that still use
|
||||
deprecated names — re-bundle after renaming.
|
||||
</Aside>
|
||||
|
||||
## Registration
|
||||
|
||||
Register plugins in your Astro configuration:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import { emdash } from "emdash/astro";
|
||||
import seoPlugin from "@emdash-cms/plugin-seo";
|
||||
import auditLogPlugin from "@emdash-cms/plugin-audit-log";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [seoPlugin({ generateSitemap: true }), auditLogPlugin({ retentionDays: 90 })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
Plugins are resolved at build time. Order matters for hooks with the same priority—earlier plugins in the array run first.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
EmDash supports two plugin execution modes:
|
||||
|
||||
| Mode | Description | Platform |
|
||||
| ------------- | ------------------------------------------ | --------------- |
|
||||
| **Sandboxed** | Isolated V8 workers with enforced limits | Cloudflare only |
|
||||
| **Native** | In-process with full access | Any |
|
||||
|
||||
In sandboxed mode, capabilities are enforced at the runtime level -- plugins can only access what they declare. In native mode, capabilities are advisory and plugins have full process access.
|
||||
|
||||
<Aside type="note">
|
||||
Sandboxed execution requires Cloudflare Workers with Dynamic Worker Loader. Other platforms use
|
||||
native mode only. See [Native vs. Sandboxed Plugins](/plugins/sandbox/) for a full comparison.
|
||||
</Aside>
|
||||
|
||||
## Next Steps
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Create a Plugin" icon="add-document">
|
||||
[Build your first plugin](/plugins/creating-plugins/) with storage, hooks, and admin UI.
|
||||
</Card>
|
||||
<Card title="Available Hooks" icon="rocket">
|
||||
[Browse all hooks](/plugins/hooks/) for content, media, and plugin lifecycle.
|
||||
</Card>
|
||||
<Card title="Plugin Storage" icon="document">
|
||||
[Learn about storage](/plugins/storage/) and how to query plugin data.
|
||||
</Card>
|
||||
<Card title="Admin UI" icon="laptop">
|
||||
[Add admin pages](/plugins/admin-ui/) and dashboard widgets.
|
||||
</Card>
|
||||
<Card title="Native vs. Sandboxed" icon="warning">
|
||||
[Compare execution modes](/plugins/sandbox/) and choose the right one for your plugin.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
200
docs/src/content/docs/plugins/publishing.mdx
Normal file
@@ -0,0 +1,200 @@
|
||||
---
|
||||
title: Publishing Plugins
|
||||
description: Bundle and publish your EmDash plugin to the marketplace.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Once you've built a plugin, you can publish it to the EmDash Marketplace so other sites can install it from the admin dashboard.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before publishing, make sure your plugin:
|
||||
|
||||
- Has a valid `package.json` with the `"."` export pointing to your plugin entry
|
||||
- Uses `definePlugin()` with a unique `id` and valid semver `version`
|
||||
- Declares its `capabilities` (what APIs it needs access to)
|
||||
|
||||
## Bundle Format
|
||||
|
||||
Published plugins are distributed as `.tar.gz` tarballs containing:
|
||||
|
||||
| File | Required | Description |
|
||||
| ---------------- | -------- | -------------------------------------- |
|
||||
| `manifest.json` | Yes | Plugin metadata extracted from `definePlugin()` |
|
||||
| `backend.js` | No | Bundled sandbox code (self-contained ES module) |
|
||||
| `admin.js` | No | Bundled admin UI code |
|
||||
| `README.md` | No | Plugin documentation |
|
||||
| `icon.png` | No | Plugin icon (256x256 PNG) |
|
||||
| `screenshots/` | No | Up to 5 screenshots (PNG/JPEG, max 1920x1080) |
|
||||
|
||||
The `manifest.json` is generated automatically from your `definePlugin()` call. It contains the plugin ID, version, capabilities, hook names, route names, and admin configuration — but no executable code.
|
||||
|
||||
## Building a Bundle
|
||||
|
||||
The `emdash plugin bundle` command produces a tarball from your plugin source:
|
||||
|
||||
```bash
|
||||
cd packages/plugins/my-plugin
|
||||
emdash plugin bundle
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
<Steps>
|
||||
1. Read your `package.json` to find entrypoints
|
||||
2. Build the main entry with tsdown to extract the manifest
|
||||
3. Bundle `backend.js` (minified, tree-shaken, self-contained)
|
||||
4. Bundle `admin.js` if an `"./admin"` export exists
|
||||
5. Collect assets (README, icon, screenshots)
|
||||
6. Validate the bundle (size limits, no Node.js built-ins in backend)
|
||||
7. Write `{id}-{version}.tar.gz` to `dist/`
|
||||
</Steps>
|
||||
|
||||
### Entrypoint Resolution
|
||||
|
||||
The bundle command finds your code through `package.json` exports:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"exports": {
|
||||
".": { "import": "./dist/index.mjs" },
|
||||
"./sandbox": { "import": "./dist/sandbox-entry.mjs" },
|
||||
"./admin": { "import": "./dist/admin.mjs" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Export | Purpose | Built as |
|
||||
| ------------ | ------- | -------- |
|
||||
| `"."` | Main entry — used to extract the manifest | Externals: `emdash`, `@emdash-cms/*` |
|
||||
| `"./sandbox"` | Backend code that runs in the sandbox | Fully self-contained (no externals) |
|
||||
| `"./admin"` | Admin UI components | Fully self-contained |
|
||||
|
||||
If `"./sandbox"` is missing, the command looks for `src/sandbox-entry.ts` as a fallback.
|
||||
|
||||
<Aside type="note">
|
||||
The bundle command maps dist paths back to source automatically. If your `"."` export points to `./dist/index.mjs`, it will find and build `src/index.ts`.
|
||||
</Aside>
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
emdash plugin bundle [--dir <path>] [--outDir <path>]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---- | ------- | ----------- |
|
||||
| `--dir` | Current directory | Plugin source directory |
|
||||
| `--outDir`, `-o` | `dist` | Output directory for the tarball |
|
||||
|
||||
### Validation
|
||||
|
||||
The bundle command checks:
|
||||
|
||||
- **Size limit** — Total bundle must be under 5MB
|
||||
- **No Node.js built-ins** — `backend.js` cannot import `fs`, `path`, `child_process`, etc. (sandbox code runs in a V8 isolate, not Node.js)
|
||||
- **Icon dimensions** — `icon.png` should be 256x256 (warns if wrong, still includes it)
|
||||
- **Screenshot limits** — Max 5 screenshots, max 1920x1080
|
||||
|
||||
<Aside type="tip">
|
||||
If your backend code imports a Node.js built-in, the bundle will fail validation. Replace Node.js APIs with Web APIs or move the logic to a native plugin instead.
|
||||
</Aside>
|
||||
|
||||
## Publishing
|
||||
|
||||
The `emdash plugin publish` command uploads your tarball to the marketplace:
|
||||
|
||||
```bash
|
||||
emdash plugin publish
|
||||
```
|
||||
|
||||
This will find the most recent `.tar.gz` in your `dist/` directory and upload it. You can also specify the tarball explicitly or build before publishing:
|
||||
|
||||
```bash
|
||||
# Explicit tarball path
|
||||
emdash plugin publish --tarball dist/my-plugin-1.0.0.tar.gz
|
||||
|
||||
# Build first, then publish
|
||||
emdash plugin publish --build
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
The first time you publish, the CLI authenticates you via GitHub:
|
||||
|
||||
<Steps>
|
||||
1. The CLI opens your browser to GitHub's device authorization page
|
||||
2. You enter the code displayed in your terminal
|
||||
3. GitHub issues an access token
|
||||
4. The CLI exchanges it for a marketplace JWT (stored in `~/.config/emdash/auth.json`)
|
||||
</Steps>
|
||||
|
||||
The token lasts 30 days. After it expires, you'll be prompted to re-authenticate on the next publish.
|
||||
|
||||
You can also manage authentication separately:
|
||||
|
||||
```bash
|
||||
# Log in without publishing
|
||||
emdash plugin login
|
||||
|
||||
# Log out (clear stored token)
|
||||
emdash plugin logout
|
||||
```
|
||||
|
||||
### First-Time Registration
|
||||
|
||||
If your plugin ID doesn't exist in the marketplace yet, `emdash plugin publish` registers it automatically before uploading the first version.
|
||||
|
||||
### Version Requirements
|
||||
|
||||
Each published version must have a higher semver than the last. You cannot overwrite or republish an existing version.
|
||||
|
||||
### Security Audit
|
||||
|
||||
Every published version goes through an automated security audit. The marketplace scans your `backend.js` and `admin.js` for:
|
||||
|
||||
- Data exfiltration patterns
|
||||
- Credential harvesting via settings
|
||||
- Obfuscated code
|
||||
- Resource abuse (crypto mining, etc.)
|
||||
- Suspicious network activity
|
||||
|
||||
The audit produces a verdict of **pass**, **warn**, or **fail**, which is displayed on the plugin's marketplace listing. Depending on the marketplace's enforcement level, a **fail** verdict may block publication entirely.
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
emdash plugin publish [--tarball <path>] [--build] [--dir <path>] [--registry <url>]
|
||||
```
|
||||
|
||||
| Flag | Default | Description |
|
||||
| ---- | ------- | ----------- |
|
||||
| `--tarball` | Latest `.tar.gz` in `dist/` | Explicit tarball path |
|
||||
| `--build` | `false` | Run `emdash plugin bundle` before publishing |
|
||||
| `--dir` | Current directory | Plugin directory (used with `--build`) |
|
||||
| `--registry` | `https://marketplace.emdashcms.com` | Marketplace URL |
|
||||
|
||||
## Complete Workflow
|
||||
|
||||
Here's the typical publish cycle:
|
||||
|
||||
```bash
|
||||
# 1. Make your changes
|
||||
# 2. Bump the version in definePlugin() and package.json
|
||||
# 3. Bundle and publish in one step
|
||||
emdash plugin publish --build
|
||||
```
|
||||
|
||||
Or if you prefer to inspect the bundle first:
|
||||
|
||||
```bash
|
||||
# Build the tarball
|
||||
emdash plugin bundle
|
||||
|
||||
# Check the output
|
||||
tar tzf dist/my-plugin-1.1.0.tar.gz
|
||||
|
||||
# Publish
|
||||
emdash plugin publish
|
||||
```
|
||||
335
docs/src/content/docs/plugins/sandbox.mdx
Normal file
@@ -0,0 +1,335 @@
|
||||
---
|
||||
title: Native vs. Sandboxed Plugins
|
||||
description: Understand the two plugin execution modes, what each supports, and why you should prefer sandboxed plugins.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash plugins run in one of two modes: **sandboxed** or **native**. Both use the same `definePlugin()` API and the same hooks, but they differ in how they're installed, what enforcement they get, and what extra features are available.
|
||||
|
||||
**Prefer sandboxed plugins.** Sandboxed plugins can be installed from the marketplace with one click -- no `npm install`, no rebuild, no redeploy. Native plugins require a code change, a dependency install, and a full rebuild. If your plugin can work within the sandbox, it should.
|
||||
|
||||
<Aside type="tip">
|
||||
The plugin code is identical in both modes. Write your plugin using the standard format, test it locally as a native plugin for fast iteration, and publish it to the marketplace as a sandboxed plugin.
|
||||
</Aside>
|
||||
|
||||
## Quick Comparison
|
||||
|
||||
| | Sandboxed | Native |
|
||||
|---|---|---|
|
||||
| **Install method** | One-click from admin UI | Code change + `npm install` + deploy |
|
||||
| **Runs in** | Isolated V8 isolate | Same process as your Astro site |
|
||||
| **Capabilities** | Enforced by RPC bridge | Advisory only (not enforced) |
|
||||
| **Resource limits** | CPU, memory, subrequests, wall-time | None |
|
||||
| **Network access** | Via `ctx.http` with host allowlist | Unrestricted |
|
||||
| **Data isolation** | Full -- scoped storage and KV | None -- shares the process |
|
||||
| **Platform** | Cloudflare Workers | All platforms |
|
||||
| **Admin UI** | Block Kit (JSON-based) | React components or Block Kit |
|
||||
| **PT block types** | Not available | Astro components via `componentsEntry` |
|
||||
| **Page fragments** | Not available | Available with `hooks.page-fragments:register` capability |
|
||||
|
||||
## What Both Modes Support
|
||||
|
||||
The core plugin API is the same regardless of execution mode. Both sandboxed and native plugins can use:
|
||||
|
||||
| Feature | Details |
|
||||
|---|---|
|
||||
| **All 22 hooks** | Content, media, email, comments, cron, page metadata, lifecycle |
|
||||
| **API routes** | REST endpoints at `/_emdash/api/plugins/<id>/<route>` |
|
||||
| **Plugin storage** | Document collections with indexes and queries |
|
||||
| **KV store** | Key-value storage for settings and state |
|
||||
| **Content CRUD** | Read, create, update, delete site content (with capability) |
|
||||
| **Media CRUD** | Read, upload, delete media files (with capability) |
|
||||
| **HTTP fetch** | External API calls (with capability and host allowlist) |
|
||||
| **User access** | Read user info (with capability) |
|
||||
| **Email** | Send email (with capability and provider configured) |
|
||||
| **Cron scheduling** | Schedule recurring tasks |
|
||||
| **Page metadata** | Contribute meta tags, OpenGraph, JSON-LD to `<head>` |
|
||||
| **Settings schema** | Auto-generated admin settings UI |
|
||||
| **Admin pages & widgets** | Via Block Kit |
|
||||
|
||||
## What Only Native Plugins Can Do
|
||||
|
||||
Three features require build-time integration that sandboxed plugins can't provide:
|
||||
|
||||
### Custom React Admin Pages
|
||||
|
||||
Native plugins can ship React components that render full admin pages and dashboard widgets. Sandboxed plugins use [Block Kit](/plugins/block-kit/) instead -- a JSON-based UI that the admin renders on the plugin's behalf.
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Native (React)">
|
||||
```typescript title="src/admin.tsx"
|
||||
export function SettingsPage({ api }) {
|
||||
const [config, setConfig] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get("settings").then(setConfig);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={() => api.post("settings/save", config)}>
|
||||
<input value={config?.apiKey} onChange={...} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Sandboxed (Block Kit)">
|
||||
```typescript title="src/sandbox-entry.ts"
|
||||
routes: {
|
||||
admin: {
|
||||
handler: async (routeCtx, ctx) => {
|
||||
const apiKey = await ctx.kv.get("settings:apiKey");
|
||||
return {
|
||||
blocks: [
|
||||
{ type: "header", text: "Settings" },
|
||||
{
|
||||
type: "input",
|
||||
element: {
|
||||
type: "text_input",
|
||||
action_id: "apiKey",
|
||||
initial_value: apiKey ?? "",
|
||||
},
|
||||
label: "API Key",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{ type: "button", text: "Save", action_id: "save" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Portable Text Block Types
|
||||
|
||||
Plugins can add custom block types to the Portable Text editor (YouTube embeds, code snippets, etc.). The editing UI uses Block Kit fields, which works in both modes. But rendering those blocks on the public site requires Astro components loaded at build time from npm -- so only native plugins can provide a `componentsEntry`.
|
||||
|
||||
Sandboxed plugins can still declare PT block types with editing fields. The site author just needs to provide their own rendering components or use a companion native package for rendering.
|
||||
|
||||
### Page Fragment Injection
|
||||
|
||||
The `page:fragments` hook injects raw HTML, scripts, or stylesheets into public pages. Because these fragments execute as first-party code in the visitor's browser -- outside any sandbox boundary -- this hook is restricted to native plugins. Sandboxed plugins can use `page:metadata` to contribute structured data (meta tags, OpenGraph, JSON-LD) instead.
|
||||
|
||||
## How Sandboxing Works
|
||||
|
||||
Sandboxed plugins run in isolated V8 isolates provided by Cloudflare's [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). The plugin code never shares memory, globals, or bindings with your Astro site.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────┐ RPC ┌──────────────────────┐
|
||||
│ Plugin Isolate │ <----------> │ PluginBridge │
|
||||
│ (V8 Worker Loader) │ (binding) │ (WorkerEntrypoint) │
|
||||
│ │ │ │
|
||||
│ ctx.kv.get(k) │─────────────>│ kvGet(k) │
|
||||
│ ctx.content.list() │─────────────>│ contentList() │
|
||||
│ ctx.http.fetch(u) │─────────────>│ httpFetch(u) │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
│
|
||||
v
|
||||
┌──────────────┐
|
||||
│ D1 / R2 │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Every `ctx` method is a proxy to the bridge. The bridge validates capabilities, scopes storage, and enforces host allowlists before touching any real resources.
|
||||
|
||||
### What the Sandbox Enforces
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Capability enforcement**
|
||||
|
||||
If a plugin declares `capabilities: ["content:read"]`, it can call `ctx.content.get()` and `ctx.content.list()` -- nothing else. Attempting `ctx.content.create()` throws a permission error. The plugin cannot bypass this because it has no direct database access.
|
||||
|
||||
2. **Resource limits**
|
||||
|
||||
Every hook or route invocation runs with hard limits:
|
||||
|
||||
| Resource | Default | Enforced by |
|
||||
|---|---|---|
|
||||
| CPU time | 50ms | Worker Loader (V8 isolate abort) |
|
||||
| Subrequests | 10 per invocation | Worker Loader (V8 isolate abort) |
|
||||
| Wall-clock time | 30 seconds | EmDash runner (`Promise.race`) |
|
||||
| Memory | ~128MB | V8 platform ceiling |
|
||||
|
||||
Exceeding CPU or subrequest limits aborts the isolate. Exceeding wall-time rejects the invocation promise.
|
||||
|
||||
3. **Network isolation**
|
||||
|
||||
Direct `fetch()` is blocked at the V8 level (`globalOutbound: null`). Plugins must use `ctx.http.fetch()`, which proxies through the bridge and validates the target host against the plugin's `allowedHosts` list.
|
||||
|
||||
4. **Storage scoping**
|
||||
|
||||
All storage and KV operations are scoped to the plugin's ID. A plugin cannot read another plugin's data, and attempting to access undeclared storage collections throws an error.
|
||||
|
||||
5. **No environment access**
|
||||
|
||||
Sandboxed plugins have no access to environment variables, the filesystem, or any host bindings. The V8 isolate context is clean.
|
||||
|
||||
</Steps>
|
||||
|
||||
### Wrangler Configuration
|
||||
|
||||
Sandboxing requires Dynamic Worker Loader. Add to your `wrangler.jsonc`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"worker_loaders": [{ "binding": "LOADER" }]
|
||||
}
|
||||
```
|
||||
|
||||
## How Native Plugins Work
|
||||
|
||||
Native plugins run in the same process as your Astro site. They're loaded from npm packages or local files and configured in `astro.config.mjs`:
|
||||
|
||||
```typescript title="astro.config.mjs"
|
||||
import myPlugin from "@emdash-cms/plugin-analytics";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
plugins: [myPlugin()],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
In native mode:
|
||||
|
||||
- **Capabilities are advisory.** A plugin declaring `["content:read"]` can still access anything in the process. The `capabilities` field documents what the plugin *intends* to use, but nothing prevents it from importing modules, calling `fetch()` directly, or reading environment variables.
|
||||
- **No resource limits.** CPU, memory, and network usage are unbounded.
|
||||
- **Full process access.** The plugin shares the runtime with your Astro site.
|
||||
|
||||
<Aside type="caution">
|
||||
Only install native plugins from sources you trust -- npm packages you've reviewed, or code you've written yourself. A native plugin has the same access as your application code.
|
||||
</Aside>
|
||||
|
||||
### Native Plugin Formats
|
||||
|
||||
Native plugins can use either of two formats:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Standard format (recommended)">
|
||||
Standard format uses a simple `{ hooks, routes }` structure. The same code can run in both native and sandboxed mode. Metadata (id, version, capabilities) comes from the plugin descriptor, not from `definePlugin()`.
|
||||
|
||||
```typescript title="src/sandbox-entry.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Content saved", { id: event.content.id });
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (routeCtx, ctx) => {
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Native format">
|
||||
Native format includes id, version, capabilities, and admin configuration directly in `definePlugin()`. This format can only run as a native plugin -- it cannot be sandboxed or published to the marketplace.
|
||||
|
||||
```typescript title="src/index.ts"
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: ["content:read"],
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
ctx.log.info("Content saved", { id: event.content.id });
|
||||
},
|
||||
},
|
||||
routes: {
|
||||
status: {
|
||||
handler: async (ctx) => {
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
},
|
||||
admin: {
|
||||
entry: "@my-org/my-plugin/admin",
|
||||
settingsSchema: { /* ... */ },
|
||||
},
|
||||
});
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
Use standard format unless you need native-only features (React admin pages, PT components, page fragments).
|
||||
|
||||
## Node.js Deployments
|
||||
|
||||
<Aside type="danger" title="No sandbox on Node.js">
|
||||
Node.js does not support plugin sandboxing. All plugins run as native plugins regardless of configuration. There is no V8 isolate boundary, no resource limits, and no capability enforcement at the runtime level.
|
||||
</Aside>
|
||||
|
||||
When deploying to Node.js (or any non-Cloudflare platform):
|
||||
|
||||
- The `NoopSandboxRunner` is used -- `isAvailable()` returns `false`
|
||||
- Attempting to load sandboxed plugins throws `SandboxNotAvailableError`
|
||||
- All plugins must be registered as native plugins in the `plugins` array
|
||||
- Capability declarations are purely informational
|
||||
|
||||
### Security Comparison by Platform
|
||||
|
||||
| Threat | Cloudflare (sandboxed) | Node.js (native only) |
|
||||
|---|---|---|
|
||||
| Plugin reads unauthorized data | Blocked by bridge capability checks | Not prevented -- full DB access |
|
||||
| Unauthorized network calls | Blocked (`globalOutbound: null` + host allowlist) | Not prevented -- direct `fetch()` |
|
||||
| CPU exhaustion | Isolate aborted by Worker Loader | Not prevented -- blocks the event loop |
|
||||
| Memory exhaustion | Isolate terminated | Not prevented -- can crash the process |
|
||||
| Env variable access | No access (isolated V8 context) | Not prevented -- shares `process.env` |
|
||||
| Filesystem access | No filesystem in Workers | Not prevented -- full `fs` access |
|
||||
|
||||
For Node.js deployments, review native plugin source code before installing and use capability declarations as a review checklist.
|
||||
|
||||
## Choosing the Right Mode
|
||||
|
||||
**Start with sandboxed.** If your plugin uses hooks, routes, storage, and the standard context API, it works in the sandbox. Most plugins fit this model.
|
||||
|
||||
**Go native when you need to:**
|
||||
|
||||
- Ship custom React admin pages (not Block Kit)
|
||||
- Provide Astro components for rendering PT block types on the public site
|
||||
- Inject scripts or HTML into public pages via `page:fragments`
|
||||
- Access Node.js APIs or environment variables directly
|
||||
- Use npm dependencies that aren't bundleable into a single ES module
|
||||
|
||||
If you're unsure, build with the standard format. You can always add native-only features later, but going the other direction -- native to sandboxed -- is harder because native-only features don't have sandbox equivalents.
|
||||
|
||||
## Same Code, Different Guarantees
|
||||
|
||||
A plugin written in standard format runs identically in both modes. What changes is the enforcement layer:
|
||||
|
||||
```typescript
|
||||
// This plugin works as both native and sandboxed
|
||||
export default definePlugin({
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
// Native mode: ctx.http is always present (capabilities not enforced)
|
||||
// Sandboxed mode: ctx.http is present because the descriptor declares "network:request"
|
||||
await ctx.http.fetch("https://api.analytics.example.com/track", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ contentId: event.content.id }),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Develop locally as a native plugin for faster iteration. Publish to the marketplace as a sandboxed plugin for production. No code changes required.
|
||||
357
docs/src/content/docs/plugins/settings.mdx
Normal file
@@ -0,0 +1,357 @@
|
||||
---
|
||||
title: Plugin Settings
|
||||
description: Configure plugins with settings schemas and the KV store.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins need configuration—API keys, feature flags, display preferences. EmDash provides two mechanisms: a **settings schema** for admin-configurable options and a **KV store** for programmatic access.
|
||||
|
||||
## Settings Schema
|
||||
|
||||
Declare a settings schema in `admin.settingsSchema` to auto-generate an admin UI:
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "seo",
|
||||
version: "1.0.0",
|
||||
|
||||
admin: {
|
||||
settingsSchema: {
|
||||
siteTitle: {
|
||||
type: "string",
|
||||
label: "Site Title",
|
||||
description: "Used in title tags and meta",
|
||||
default: "",
|
||||
},
|
||||
maxTitleLength: {
|
||||
type: "number",
|
||||
label: "Max Title Length",
|
||||
description: "Characters before truncation",
|
||||
default: 60,
|
||||
min: 30,
|
||||
max: 100,
|
||||
},
|
||||
generateSitemap: {
|
||||
type: "boolean",
|
||||
label: "Generate Sitemap",
|
||||
description: "Automatically generate sitemap.xml",
|
||||
default: true,
|
||||
},
|
||||
defaultRobots: {
|
||||
type: "select",
|
||||
label: "Default Robots",
|
||||
options: [
|
||||
{ value: "index,follow", label: "Index & Follow" },
|
||||
{ value: "noindex,follow", label: "No Index, Follow" },
|
||||
{ value: "noindex,nofollow", label: "No Index, No Follow" },
|
||||
],
|
||||
default: "index,follow",
|
||||
},
|
||||
apiKey: {
|
||||
type: "secret",
|
||||
label: "API Key",
|
||||
description: "Encrypted at rest",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
EmDash generates a settings form in the plugin's admin section. Users edit settings without touching code.
|
||||
|
||||
## Field Types
|
||||
|
||||
### String
|
||||
|
||||
Text input for single-line or multiline strings.
|
||||
|
||||
```typescript
|
||||
siteTitle: {
|
||||
type: "string",
|
||||
label: "Site Title",
|
||||
description: "Optional help text",
|
||||
default: "My Site",
|
||||
multiline: false // Set true for textarea
|
||||
}
|
||||
```
|
||||
|
||||
### Number
|
||||
|
||||
Numeric input with optional min/max constraints.
|
||||
|
||||
```typescript
|
||||
maxItems: {
|
||||
type: "number",
|
||||
label: "Maximum Items",
|
||||
default: 100,
|
||||
min: 1,
|
||||
max: 1000
|
||||
}
|
||||
```
|
||||
|
||||
### Boolean
|
||||
|
||||
Toggle switch for true/false values.
|
||||
|
||||
```typescript
|
||||
enabled: {
|
||||
type: "boolean",
|
||||
label: "Enabled",
|
||||
description: "Turn this feature on or off",
|
||||
default: true
|
||||
}
|
||||
```
|
||||
|
||||
### Select
|
||||
|
||||
Dropdown for predefined options.
|
||||
|
||||
```typescript
|
||||
theme: {
|
||||
type: "select",
|
||||
label: "Theme",
|
||||
options: [
|
||||
{ value: "light", label: "Light" },
|
||||
{ value: "dark", label: "Dark" },
|
||||
{ value: "auto", label: "System" }
|
||||
],
|
||||
default: "auto"
|
||||
}
|
||||
```
|
||||
|
||||
### Secret
|
||||
|
||||
Encrypted field for sensitive values like API keys. Never sent to the client after saving.
|
||||
|
||||
```typescript
|
||||
apiKey: {
|
||||
type: "secret",
|
||||
label: "API Key",
|
||||
description: "Stored encrypted"
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Secret fields are encrypted at rest using the site's encryption key. They're never exposed in the
|
||||
admin UI after the initial save—only a masked placeholder is shown.
|
||||
</Aside>
|
||||
### Url
|
||||
|
||||
Single-line text input with URL validation.
|
||||
|
||||
```typescript
|
||||
website: {
|
||||
type: "url",
|
||||
label: "Website URL",
|
||||
description: "Enter your website address",
|
||||
default: "https://",
|
||||
}
|
||||
```
|
||||
|
||||
### Email
|
||||
|
||||
Text input with email format validation.
|
||||
|
||||
```typescript
|
||||
contactEmail: {
|
||||
type: "email",
|
||||
label: "Contact Email",
|
||||
description: "Support contact address",
|
||||
default: "",
|
||||
}
|
||||
```
|
||||
|
||||
### Accessing Settings
|
||||
|
||||
Read settings in hooks and routes via `ctx.kv`:
|
||||
|
||||
```typescript
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
// Read a setting
|
||||
const maxLength = await ctx.kv.get<number>("settings:maxTitleLength");
|
||||
const apiKey = await ctx.kv.get<string>("settings:apiKey");
|
||||
|
||||
// Use defaults if not set
|
||||
const limit = maxLength ?? 60;
|
||||
|
||||
ctx.log.info("Using max length", { limit });
|
||||
return event.content;
|
||||
}
|
||||
```
|
||||
|
||||
Settings are stored with the `settings:` prefix by convention. This distinguishes user-configurable values from internal plugin state.
|
||||
|
||||
## KV Store API
|
||||
|
||||
The KV store (`ctx.kv`) is a general-purpose key-value store for plugin data:
|
||||
|
||||
```typescript
|
||||
interface KVAccess {
|
||||
get<T>(key: string): Promise<T | null>;
|
||||
set(key: string, value: unknown): Promise<void>;
|
||||
delete(key: string): Promise<boolean>;
|
||||
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
|
||||
}
|
||||
```
|
||||
|
||||
### Reading Values
|
||||
|
||||
```typescript
|
||||
// Get a single value
|
||||
const enabled = await ctx.kv.get<boolean>("settings:enabled");
|
||||
|
||||
// Get with type
|
||||
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");
|
||||
```
|
||||
|
||||
### Writing Values
|
||||
|
||||
```typescript
|
||||
// Set a value
|
||||
await ctx.kv.set("settings:lastSync", new Date().toISOString());
|
||||
|
||||
// Set complex values
|
||||
await ctx.kv.set("state:cache", {
|
||||
data: items,
|
||||
expiry: Date.now() + 3600000,
|
||||
});
|
||||
```
|
||||
|
||||
### Listing Values
|
||||
|
||||
```typescript
|
||||
// List all settings
|
||||
const settings = await ctx.kv.list("settings:");
|
||||
// Returns: [{ key: "settings:enabled", value: true }, ...]
|
||||
|
||||
// List all plugin keys
|
||||
const all = await ctx.kv.list();
|
||||
```
|
||||
|
||||
### Deleting Values
|
||||
|
||||
```typescript
|
||||
const deleted = await ctx.kv.delete("state:tempData");
|
||||
// Returns true if key existed
|
||||
```
|
||||
|
||||
## Key Naming Conventions
|
||||
|
||||
Use prefixes to organize KV data:
|
||||
|
||||
| Prefix | Purpose | Example |
|
||||
| ----------- | ----------------------------- | ----------------- |
|
||||
| `settings:` | User-configurable preferences | `settings:apiKey` |
|
||||
| `state:` | Internal plugin state | `state:lastSync` |
|
||||
| `cache:` | Cached data | `cache:results` |
|
||||
|
||||
```typescript
|
||||
// Good: clear prefixes
|
||||
await ctx.kv.set("settings:webhookUrl", url);
|
||||
await ctx.kv.set("state:lastRun", timestamp);
|
||||
await ctx.kv.set("cache:feed", feedData);
|
||||
|
||||
// Avoid: no prefix, unclear purpose
|
||||
await ctx.kv.set("url", url);
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
The `settings:` prefix is a convention for values shown in the auto-generated settings UI. Other
|
||||
prefixes are for plugin-internal use.
|
||||
</Aside>
|
||||
|
||||
## Settings vs Storage vs KV
|
||||
|
||||
Choose the right storage mechanism:
|
||||
|
||||
| Use Case | Mechanism |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| Admin-editable preferences | `admin.settingsSchema` + `ctx.kv` with `settings:` |
|
||||
| Internal plugin state | `ctx.kv` with `state:` |
|
||||
| Collections of documents | `ctx.storage` |
|
||||
|
||||
**Settings** are for user-configurable values—things an admin might change. They get an auto-generated UI.
|
||||
|
||||
**KV** is for internal state like timestamps, sync cursors, or cached computations. No UI, just code.
|
||||
|
||||
**Storage** is for document collections with indexed queries—form submissions, audit logs, etc.
|
||||
|
||||
## Loading Settings in Routes
|
||||
|
||||
API routes can expose settings to admin UI components:
|
||||
|
||||
```typescript
|
||||
routes: {
|
||||
settings: {
|
||||
handler: async (ctx) => {
|
||||
const settings = await ctx.kv.list("settings:");
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
for (const entry of settings) {
|
||||
const key = entry.key.replace("settings:", "");
|
||||
result[key] = entry.value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
},
|
||||
|
||||
"settings/save": {
|
||||
handler: async (ctx) => {
|
||||
const input = ctx.input as Record<string, unknown>;
|
||||
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value !== undefined) {
|
||||
await ctx.kv.set(`settings:${key}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Default Values
|
||||
|
||||
Settings from `settingsSchema` are not automatically persisted. They're defaults in the admin UI. Your code should handle missing values:
|
||||
|
||||
```typescript
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
// Always provide a fallback
|
||||
const enabled = await ctx.kv.get<boolean>("settings:enabled") ?? true;
|
||||
const maxItems = await ctx.kv.get<number>("settings:maxItems") ?? 100;
|
||||
|
||||
if (!enabled) return;
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Alternatively, persist defaults in `plugin:install`:
|
||||
|
||||
```typescript
|
||||
hooks: {
|
||||
"plugin:install": async (_event, ctx) => {
|
||||
// Persist schema defaults
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.kv.set("settings:maxItems", 100);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Storage Implementation
|
||||
|
||||
KV values are stored in the `_options` table with plugin-namespaced keys:
|
||||
|
||||
```sql
|
||||
INSERT INTO _options (name, value) VALUES
|
||||
('plugin:seo:settings:siteTitle', '"My Site"'),
|
||||
('plugin:seo:settings:maxTitleLength', '60');
|
||||
```
|
||||
|
||||
The `plugin:seo:` prefix is added automatically. Your code uses `settings:siteTitle`, and EmDash stores it as `plugin:seo:settings:siteTitle`.
|
||||
|
||||
This ensures plugins can't accidentally overwrite each other's data.
|
||||
360
docs/src/content/docs/plugins/storage.mdx
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
title: Plugin Storage
|
||||
description: Persist plugin data in document collections with indexed queries.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Plugins can store their own data in document collections without writing database migrations. Declare collections and indexes in your plugin definition, and EmDash handles the schema automatically.
|
||||
|
||||
## Declaring Storage
|
||||
|
||||
Define storage collections in `definePlugin()`:
|
||||
|
||||
```typescript
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "forms",
|
||||
version: "1.0.0",
|
||||
|
||||
storage: {
|
||||
submissions: {
|
||||
indexes: [
|
||||
"formId", // Single-field index
|
||||
"status",
|
||||
"createdAt",
|
||||
["formId", "createdAt"], // Composite index
|
||||
["status", "createdAt"],
|
||||
],
|
||||
},
|
||||
forms: {
|
||||
indexes: ["slug"],
|
||||
},
|
||||
},
|
||||
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
Each key in `storage` is a collection name. The `indexes` array lists fields that can be queried efficiently.
|
||||
|
||||
<Aside type="note">
|
||||
Storage is scoped to the plugin. A `submissions` collection in the `forms` plugin is completely
|
||||
separate from `submissions` in another plugin.
|
||||
</Aside>
|
||||
|
||||
## Storage Collection API
|
||||
|
||||
Access collections via `ctx.storage` in hooks and routes:
|
||||
|
||||
```typescript
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const { submissions } = ctx.storage;
|
||||
|
||||
// CRUD operations
|
||||
await submissions.put("sub_123", { formId: "contact", email: "user@example.com" });
|
||||
const item = await submissions.get("sub_123");
|
||||
const exists = await submissions.exists("sub_123");
|
||||
await submissions.delete("sub_123");
|
||||
}
|
||||
```
|
||||
|
||||
### Full API Reference
|
||||
|
||||
```typescript
|
||||
interface StorageCollection<T = unknown> {
|
||||
// Basic CRUD
|
||||
get(id: string): Promise<T | null>;
|
||||
put(id: string, data: T): Promise<void>;
|
||||
delete(id: string): Promise<boolean>;
|
||||
exists(id: string): Promise<boolean>;
|
||||
|
||||
// Batch operations
|
||||
getMany(ids: string[]): Promise<Map<string, T>>;
|
||||
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
|
||||
deleteMany(ids: string[]): Promise<number>;
|
||||
|
||||
// Query (indexed fields only)
|
||||
query(options?: QueryOptions): Promise<PaginatedResult<{ id: string; data: T }>>;
|
||||
count(where?: WhereClause): Promise<number>;
|
||||
}
|
||||
```
|
||||
|
||||
## Querying Data
|
||||
|
||||
Use `query()` to retrieve documents matching criteria. Queries return paginated results.
|
||||
|
||||
```typescript
|
||||
const result = await ctx.storage.submissions.query({
|
||||
where: {
|
||||
formId: "contact",
|
||||
status: "pending",
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// result.items - Array of { id, data }
|
||||
// result.cursor - Pagination cursor (if more results)
|
||||
// result.hasMore - Boolean indicating more pages
|
||||
```
|
||||
|
||||
### Query Options
|
||||
|
||||
```typescript
|
||||
interface QueryOptions {
|
||||
where?: WhereClause;
|
||||
orderBy?: Record<string, "asc" | "desc">;
|
||||
limit?: number; // Default 50, max 1000
|
||||
cursor?: string; // For pagination
|
||||
}
|
||||
```
|
||||
|
||||
### Where Clause Operators
|
||||
|
||||
Filter by indexed fields using these operators:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="Exact Match">
|
||||
```typescript
|
||||
where: {
|
||||
status: "pending", // Exact string match
|
||||
count: 5, // Exact number match
|
||||
archived: false // Exact boolean match
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="Range">
|
||||
```typescript
|
||||
where: {
|
||||
createdAt: { gte: "2024-01-01" }, // Greater than or equal
|
||||
score: { gt: 50, lte: 100 } // Between (exclusive/inclusive)
|
||||
}
|
||||
|
||||
// Available: gt, gte, lt, lte
|
||||
|
||||
````
|
||||
</TabItem>
|
||||
<TabItem label="In">
|
||||
```typescript
|
||||
where: {
|
||||
status: { in: ["pending", "approved"] }
|
||||
}
|
||||
````
|
||||
|
||||
</TabItem>
|
||||
<TabItem label="Starts With">
|
||||
```typescript
|
||||
where: {
|
||||
slug: { startsWith: "blog-" }
|
||||
}
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Ordering
|
||||
|
||||
Order results by indexed fields:
|
||||
|
||||
```typescript
|
||||
orderBy: {
|
||||
createdAt: "desc";
|
||||
} // Newest first
|
||||
orderBy: {
|
||||
score: "asc";
|
||||
} // Lowest first
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
You can only query and order by indexed fields. Queries on non-indexed fields throw an error. This
|
||||
prevents accidental full-table scans.
|
||||
</Aside>
|
||||
|
||||
## Pagination
|
||||
|
||||
Results are paginated. Use `cursor` to fetch additional pages:
|
||||
|
||||
```typescript
|
||||
async function getAllSubmissions(ctx: PluginContext) {
|
||||
const allItems = [];
|
||||
let cursor: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await ctx.storage.submissions!.query({
|
||||
orderBy: { createdAt: "desc" },
|
||||
limit: 100,
|
||||
cursor,
|
||||
});
|
||||
|
||||
allItems.push(...result.items);
|
||||
cursor = result.cursor;
|
||||
} while (cursor);
|
||||
|
||||
return allItems;
|
||||
}
|
||||
```
|
||||
|
||||
### PaginatedResult
|
||||
|
||||
```typescript
|
||||
interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
cursor?: string; // Pass to next query for more results
|
||||
hasMore: boolean; // True if more pages exist
|
||||
}
|
||||
```
|
||||
|
||||
## Counting Documents
|
||||
|
||||
Count documents matching criteria:
|
||||
|
||||
```typescript
|
||||
// Count all
|
||||
const total = await ctx.storage.submissions!.count();
|
||||
|
||||
// Count with filter
|
||||
const pending = await ctx.storage.submissions!.count({
|
||||
status: "pending",
|
||||
});
|
||||
```
|
||||
|
||||
## Batch Operations
|
||||
|
||||
For bulk operations, use batch methods:
|
||||
|
||||
```typescript
|
||||
// Get multiple by ID
|
||||
const items = await ctx.storage.submissions!.getMany(["sub_1", "sub_2", "sub_3"]);
|
||||
// Returns Map<string, T>
|
||||
|
||||
// Put multiple
|
||||
await ctx.storage.submissions!.putMany([
|
||||
{ id: "sub_1", data: { formId: "contact", status: "new" } },
|
||||
{ id: "sub_2", data: { formId: "contact", status: "new" } },
|
||||
]);
|
||||
|
||||
// Delete multiple
|
||||
const deletedCount = await ctx.storage.submissions!.deleteMany(["sub_1", "sub_2"]);
|
||||
```
|
||||
|
||||
## Index Design
|
||||
|
||||
Choose indexes based on your query patterns:
|
||||
|
||||
| Query Pattern | Index Needed |
|
||||
| ---------------------------------------- | ------------------------------------ |
|
||||
| Filter by `formId` | `"formId"` |
|
||||
| Filter by `formId`, order by `createdAt` | `["formId", "createdAt"]` |
|
||||
| Order by `createdAt` only | `"createdAt"` |
|
||||
| Filter by `status` and `formId` | `"status"` and `"formId"` (separate) |
|
||||
|
||||
Composite indexes support queries that filter on the first field and optionally order by the second:
|
||||
|
||||
```typescript
|
||||
// With index ["formId", "createdAt"]:
|
||||
|
||||
// This works:
|
||||
query({ where: { formId: "contact" }, orderBy: { createdAt: "desc" } });
|
||||
|
||||
// This also works (filter only):
|
||||
query({ where: { formId: "contact" } });
|
||||
|
||||
// This does NOT use the composite index (wrong field order):
|
||||
query({ where: { createdAt: { gte: "2024-01-01" } } });
|
||||
```
|
||||
|
||||
## Type Safety
|
||||
|
||||
Type your storage collections for better IntelliSense:
|
||||
|
||||
```typescript
|
||||
interface Submission {
|
||||
formId: string;
|
||||
email: string;
|
||||
data: Record<string, unknown>;
|
||||
status: "pending" | "approved" | "spam";
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
definePlugin({
|
||||
id: "forms",
|
||||
version: "1.0.0",
|
||||
|
||||
storage: {
|
||||
submissions: {
|
||||
indexes: ["formId", "status", "createdAt"],
|
||||
},
|
||||
},
|
||||
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
// Cast to typed collection
|
||||
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
|
||||
|
||||
const submission: Submission = {
|
||||
formId: "contact",
|
||||
email: "user@example.com",
|
||||
data: { message: "Hello" },
|
||||
status: "pending",
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await submissions.put(`sub_${Date.now()}`, submission);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Storage vs Content vs KV
|
||||
|
||||
Use the right storage mechanism for your use case:
|
||||
|
||||
| Use Case | Storage |
|
||||
| -------------------------------------------------- | ------------------------------------- |
|
||||
| Plugin operational data (logs, submissions, cache) | `ctx.storage` |
|
||||
| User-configurable settings | `ctx.kv` with `settings:` prefix |
|
||||
| Internal plugin state | `ctx.kv` with `state:` prefix |
|
||||
| Content editable in admin UI | Site collections (not plugin storage) |
|
||||
|
||||
<Aside type="tip">
|
||||
Plugin storage is for data the plugin owns and manages internally. If content editors need to view
|
||||
or edit the data in the admin UI, create a site collection instead.
|
||||
</Aside>
|
||||
|
||||
## Implementation Details
|
||||
|
||||
Under the hood, plugin storage uses a single database table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE _plugin_storage (
|
||||
plugin_id TEXT NOT NULL,
|
||||
collection TEXT NOT NULL,
|
||||
id TEXT NOT NULL,
|
||||
data JSON NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT,
|
||||
PRIMARY KEY (plugin_id, collection, id)
|
||||
);
|
||||
```
|
||||
|
||||
EmDash creates expression indexes for declared fields:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_forms_submissions_formId
|
||||
ON _plugin_storage(json_extract(data, '$.formId'))
|
||||
WHERE plugin_id = 'forms' AND collection = 'submissions';
|
||||
```
|
||||
|
||||
This design provides:
|
||||
|
||||
- **No migrations** — Schema lives in plugin code
|
||||
- **Portability** — Works on D1, libSQL, SQLite
|
||||
- **Isolation** — Plugins can only access their own data
|
||||
- **Safety** — No SQL injection, validated queries
|
||||
|
||||
## Adding Indexes
|
||||
|
||||
When you add indexes in a plugin update, EmDash creates them automatically on next startup. This is safe—indexes can be added without data migration.
|
||||
|
||||
When you remove indexes, EmDash drops them. Queries on non-indexed fields will fail with a validation error.
|
||||
493
docs/src/content/docs/reference/api.mdx
Normal file
@@ -0,0 +1,493 @@
|
||||
---
|
||||
title: JavaScript API Reference
|
||||
description: Programmatic API for querying and managing EmDash content.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash exports functions for querying content, managing media, and working with the database.
|
||||
|
||||
## Content Queries
|
||||
|
||||
EmDash's query functions follow Astro's [live content collections](https://docs.astro.build/en/reference/experimental-flags/live-content-collections/) pattern, returning `{ entries, error }` or `{ entry, error }` for graceful error handling.
|
||||
|
||||
### `getEmDashCollection()`
|
||||
|
||||
Fetch all entries from a collection.
|
||||
|
||||
```ts
|
||||
import { getEmDashCollection } from "emdash";
|
||||
|
||||
const { entries: posts, error } = await getEmDashCollection("posts");
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to load posts:", error);
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | ------------------ | ----------------------- |
|
||||
| `collection` | `string` | Collection slug |
|
||||
| `options` | `CollectionFilter` | Optional filter options |
|
||||
|
||||
#### Options
|
||||
|
||||
```ts
|
||||
interface CollectionFilter {
|
||||
status?: "draft" | "published" | "archived";
|
||||
limit?: number;
|
||||
where?: Record<string, string | string[]>; // Filter by field or taxonomy
|
||||
}
|
||||
```
|
||||
|
||||
#### Returns
|
||||
|
||||
```ts
|
||||
interface CollectionResult<T> {
|
||||
entries: ContentEntry<T>[]; // Empty array if error or none found
|
||||
error?: Error; // Set if query failed
|
||||
}
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```ts
|
||||
// Get all published posts
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Get latest 5 posts
|
||||
const { entries: latest } = await getEmDashCollection("posts", {
|
||||
limit: 5,
|
||||
status: "published",
|
||||
});
|
||||
|
||||
// Filter by taxonomy
|
||||
const { entries: newsPosts } = await getEmDashCollection("posts", {
|
||||
status: "published",
|
||||
where: { category: "news" },
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
const { entries, error } = await getEmDashCollection("posts");
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
```
|
||||
|
||||
### `getEmDashEntry()`
|
||||
|
||||
Fetch a single entry by slug or ID.
|
||||
|
||||
```ts
|
||||
import { getEmDashEntry } from "emdash";
|
||||
|
||||
const { entry: post, error } = await getEmDashEntry("posts", "my-post-slug");
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
```
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | -------- | ---------------- |
|
||||
| `collection` | `string` | Collection slug |
|
||||
| `slugOrId` | `string` | Entry slug or ID |
|
||||
|
||||
Preview mode is handled automatically — the middleware detects `_preview` tokens and serves draft content via `AsyncLocalStorage`. No options parameter is needed.
|
||||
|
||||
#### Returns
|
||||
|
||||
```ts
|
||||
interface EntryResult<T> {
|
||||
entry: ContentEntry<T> | null; // null if not found
|
||||
error?: Error; // Set only for actual errors, not "not found"
|
||||
isPreview: boolean; // true if draft content is being served
|
||||
}
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
```ts
|
||||
// Get by slug
|
||||
const { entry: post } = await getEmDashEntry("posts", "hello-world");
|
||||
|
||||
// Get by ID
|
||||
const { entry: post } = await getEmDashEntry("posts", "01HXK5MZSN0FVXT2Q3KPRT9M7D");
|
||||
|
||||
// Preview is automatic — isPreview is true when a valid _preview token is present
|
||||
const { entry, isPreview, error } = await getEmDashEntry("posts", slug);
|
||||
|
||||
// Handle errors vs not-found
|
||||
if (error) {
|
||||
return new Response("Server error", { status: 500 });
|
||||
}
|
||||
if (!entry) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
```
|
||||
|
||||
## Content Types
|
||||
|
||||
### `ContentEntry`
|
||||
|
||||
The entry returned by query functions:
|
||||
|
||||
```ts
|
||||
interface ContentEntry<T = Record<string, unknown>> {
|
||||
id: string;
|
||||
data: T;
|
||||
edit: EditProxy; // Visual editing annotations
|
||||
}
|
||||
```
|
||||
|
||||
The `edit` proxy provides visual editing annotations. Spread it onto elements to enable inline editing: `{...entry.edit.title}`. In production, this produces no output.
|
||||
|
||||
The `data` object contains all content fields plus system fields:
|
||||
|
||||
- `id` - Unique identifier
|
||||
- `slug` - URL-friendly identifier
|
||||
- `status` - "draft" | "published" | "archived"
|
||||
- `createdAt` - ISO timestamp
|
||||
- `updatedAt` - ISO timestamp
|
||||
- `publishedAt` - ISO timestamp or null
|
||||
- Plus all custom fields defined in your collection schema
|
||||
|
||||
## Database Functions
|
||||
|
||||
### `createDatabase()`
|
||||
|
||||
Create a database connection.
|
||||
|
||||
```ts
|
||||
import { createDatabase } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
Direct database access is for advanced use cases. Prefer the query functions for content access.
|
||||
</Aside>
|
||||
|
||||
### `runMigrations()`
|
||||
|
||||
Run pending database migrations.
|
||||
|
||||
```ts
|
||||
import { createDatabase, runMigrations } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
const { applied } = await runMigrations(db);
|
||||
console.log(`Applied ${applied.length} migrations`);
|
||||
```
|
||||
|
||||
### `getMigrationStatus()`
|
||||
|
||||
Check migration status.
|
||||
|
||||
```ts
|
||||
import { createDatabase, getMigrationStatus } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
const status = await getMigrationStatus(db);
|
||||
// { applied: ["0001_core", ...], pending: [] }
|
||||
```
|
||||
|
||||
## Repositories
|
||||
|
||||
Low-level data access through repositories.
|
||||
|
||||
### `ContentRepository`
|
||||
|
||||
```ts
|
||||
import { ContentRepository, createDatabase } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
const repo = new ContentRepository(db);
|
||||
|
||||
// Find many
|
||||
const { items, nextCursor } = await repo.findMany("posts", {
|
||||
limit: 10,
|
||||
where: { status: "published" },
|
||||
});
|
||||
|
||||
// Find by ID
|
||||
const post = await repo.findById("posts", "01HXK5MZSN...");
|
||||
|
||||
// Create
|
||||
const newPost = await repo.create({
|
||||
type: "posts",
|
||||
slug: "new-post",
|
||||
data: { title: "New Post", content: [] },
|
||||
status: "draft",
|
||||
});
|
||||
|
||||
// Update
|
||||
const updated = await repo.update("posts", "01HXK5MZSN...", {
|
||||
data: { title: "Updated Title" },
|
||||
});
|
||||
|
||||
// Delete
|
||||
await repo.delete("posts", "01HXK5MZSN...");
|
||||
```
|
||||
|
||||
### `MediaRepository`
|
||||
|
||||
```ts
|
||||
import { MediaRepository, createDatabase } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
const repo = new MediaRepository(db);
|
||||
|
||||
// List media
|
||||
const { items } = await repo.findMany({ limit: 20 });
|
||||
|
||||
// Get by ID
|
||||
const media = await repo.findById("01HXK5MZSN...");
|
||||
|
||||
// Create (after upload)
|
||||
const newMedia = await repo.create({
|
||||
filename: "photo.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
size: 12345,
|
||||
storageKey: "uploads/photo.jpg",
|
||||
});
|
||||
```
|
||||
|
||||
## Schema Registry
|
||||
|
||||
Programmatic schema management.
|
||||
|
||||
```ts
|
||||
import { SchemaRegistry, createDatabase } from "emdash";
|
||||
|
||||
const db = createDatabase({ url: "file:./data.db" });
|
||||
const registry = new SchemaRegistry(db);
|
||||
|
||||
// List collections
|
||||
const collections = await registry.listCollections();
|
||||
|
||||
// Get collection with fields
|
||||
const postsSchema = await registry.getCollectionWithFields("posts");
|
||||
|
||||
// Create collection
|
||||
await registry.createCollection({
|
||||
slug: "products",
|
||||
label: "Products",
|
||||
labelSingular: "Product",
|
||||
supports: ["drafts", "revisions"],
|
||||
});
|
||||
|
||||
// Add field
|
||||
await registry.createField("products", {
|
||||
slug: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
required: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Preview System
|
||||
|
||||
### `generatePreviewToken()`
|
||||
|
||||
Generate a preview token for draft content.
|
||||
|
||||
```ts
|
||||
import { generatePreviewToken } from "emdash";
|
||||
|
||||
const token = await generatePreviewToken({
|
||||
contentId: "posts:01HXK5MZSN...",
|
||||
secret: process.env.EMDASH_ADMIN_SECRET,
|
||||
expiresIn: 3600, // 1 hour
|
||||
});
|
||||
```
|
||||
|
||||
### `verifyPreviewToken()`
|
||||
|
||||
Verify a preview token.
|
||||
|
||||
```ts
|
||||
import { verifyPreviewToken } from "emdash";
|
||||
|
||||
const result = await verifyPreviewToken({
|
||||
token,
|
||||
secret: process.env.EMDASH_ADMIN_SECRET,
|
||||
});
|
||||
|
||||
if (result.valid) {
|
||||
const { cid, exp, iat } = result.payload;
|
||||
// cid is "collection:id" format, e.g. "posts:my-draft-post"
|
||||
}
|
||||
```
|
||||
|
||||
### `isPreviewRequest()`
|
||||
|
||||
Check if a request includes a preview token.
|
||||
|
||||
```ts
|
||||
import { isPreviewRequest, getPreviewToken } from "emdash";
|
||||
|
||||
if (isPreviewRequest(Astro.request)) {
|
||||
const token = getPreviewToken(Astro.request);
|
||||
// Verify and show preview content
|
||||
}
|
||||
```
|
||||
|
||||
## Content Converters
|
||||
|
||||
Convert between Portable Text and ProseMirror formats.
|
||||
|
||||
```ts
|
||||
import { prosemirrorToPortableText, portableTextToProsemirror } from "emdash";
|
||||
|
||||
// From ProseMirror (editor) to Portable Text (storage)
|
||||
const portableText = prosemirrorToPortableText(prosemirrorDoc);
|
||||
|
||||
// From Portable Text to ProseMirror
|
||||
const prosemirrorDoc = portableTextToProsemirror(portableText);
|
||||
```
|
||||
|
||||
## Site Settings
|
||||
|
||||
```ts
|
||||
import { getSiteSettings, getSiteSetting } from "emdash";
|
||||
|
||||
// Get all settings
|
||||
const settings = await getSiteSettings();
|
||||
|
||||
// Get single setting
|
||||
const title = await getSiteSetting("siteTitle");
|
||||
```
|
||||
|
||||
Settings are read-only from the runtime API. Use the admin API to update them.
|
||||
|
||||
## Menus
|
||||
|
||||
```ts
|
||||
import { getMenu, getMenus } from "emdash";
|
||||
|
||||
// Get all menus
|
||||
const menus = await getMenus();
|
||||
|
||||
// Get specific menu with items
|
||||
const primaryMenu = await getMenu("primary");
|
||||
|
||||
if (primaryMenu) {
|
||||
primaryMenu.items.forEach(item => {
|
||||
console.log(item.label, item.url);
|
||||
// Nested items for dropdowns
|
||||
item.children.forEach(child => console.log(" -", child.label));
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Taxonomies
|
||||
|
||||
```ts
|
||||
import { getTaxonomyTerms, getTerm, getEntryTerms, getEntriesByTerm } from "emdash";
|
||||
|
||||
// Get all terms for a taxonomy (tree structure for hierarchical)
|
||||
const categories = await getTaxonomyTerms("category");
|
||||
|
||||
// Get single term
|
||||
const news = await getTerm("category", "news");
|
||||
|
||||
// Get terms assigned to a content entry
|
||||
const postCategories = await getEntryTerms("posts", "post-123", "category");
|
||||
|
||||
// Get entries with a specific term
|
||||
const newsPosts = await getEntriesByTerm("posts", "category", "news");
|
||||
```
|
||||
|
||||
## Widget Areas
|
||||
|
||||
```ts
|
||||
import { getWidgetArea, getWidgetAreas } from "emdash";
|
||||
|
||||
// Get all widget areas
|
||||
const areas = await getWidgetAreas();
|
||||
|
||||
// Get specific widget area with widgets
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
|
||||
if (sidebar) {
|
||||
sidebar.widgets.forEach(widget => {
|
||||
console.log(widget.type, widget.title);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Sections
|
||||
|
||||
```ts
|
||||
import { getSection, getSections, getSectionCategories } from "emdash";
|
||||
|
||||
// Get all sections
|
||||
const sections = await getSections();
|
||||
|
||||
// Filter sections
|
||||
const heroes = await getSections({ category: "hero" });
|
||||
const themeSections = await getSections({ source: "theme" });
|
||||
const results = await getSections({ search: "newsletter" });
|
||||
|
||||
// Get single section
|
||||
const cta = await getSection("newsletter-cta");
|
||||
|
||||
// Get categories
|
||||
const categories = await getSectionCategories();
|
||||
```
|
||||
|
||||
## Search
|
||||
|
||||
```ts
|
||||
import { search, searchCollection } from "emdash";
|
||||
|
||||
// Global search across collections
|
||||
const results = await search("hello world", {
|
||||
collections: ["posts", "pages"],
|
||||
status: "published",
|
||||
limit: 20,
|
||||
});
|
||||
|
||||
// Results include snippets with highlights
|
||||
results.forEach(result => {
|
||||
console.log(result.title);
|
||||
console.log(result.snippet); // Contains <mark> tags
|
||||
console.log(result.score);
|
||||
});
|
||||
|
||||
// Collection-specific search
|
||||
const posts = await searchCollection("posts", "typescript", {
|
||||
limit: 10,
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
EmDash exports error classes for handling specific failures:
|
||||
|
||||
```ts
|
||||
import {
|
||||
EmDashDatabaseError,
|
||||
EmDashValidationError,
|
||||
EmDashStorageError,
|
||||
SchemaError,
|
||||
} from "emdash";
|
||||
|
||||
try {
|
||||
await repo.create({ ... });
|
||||
} catch (error) {
|
||||
if (error instanceof EmDashValidationError) {
|
||||
console.error("Validation failed:", error.message);
|
||||
}
|
||||
if (error instanceof SchemaError) {
|
||||
console.error("Schema error:", error.code, error.details);
|
||||
}
|
||||
}
|
||||
```
|
||||
647
docs/src/content/docs/reference/cli.mdx
Normal file
@@ -0,0 +1,647 @@
|
||||
---
|
||||
title: CLI Reference
|
||||
description: Command-line interface for EmDash CMS.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
The EmDash CLI provides commands for managing an EmDash CMS instance — database setup, type generation, content CRUD, schema management, media, and more.
|
||||
|
||||
## Installation
|
||||
|
||||
The CLI is included with the `emdash` package:
|
||||
|
||||
```bash
|
||||
npm install emdash
|
||||
```
|
||||
|
||||
Run commands with `npx emdash` or add scripts to `package.json`. The binary is also available as `em` for brevity.
|
||||
|
||||
## Authentication
|
||||
|
||||
Commands that talk to a running EmDash instance (everything except `init`, `seed`, `export-seed`, and `secrets`) resolve authentication in this order:
|
||||
|
||||
1. **`--token` flag** — explicit token on the command line
|
||||
2. **`EMDASH_TOKEN` env var**
|
||||
3. **Stored credentials** from `~/.config/emdash/auth.json` (saved by `emdash login`)
|
||||
4. **Dev bypass** — if the URL is localhost and no token is available, automatically authenticates via the dev bypass endpoint
|
||||
|
||||
Most commands accept `--url` (default `http://localhost:4321`) and `--token` flags. When targeting a local dev server, no token is needed.
|
||||
|
||||
## Common Flags
|
||||
|
||||
These flags are available on all remote commands:
|
||||
|
||||
| Flag | Alias | Description | Default |
|
||||
| --------- | ----- | --------------------------- | ------------------------ |
|
||||
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
|
||||
| `--token` | `-t` | Auth token | From env/stored creds |
|
||||
| `--json` | | Output as JSON (for piping) | Auto-detected from TTY |
|
||||
|
||||
## Output
|
||||
|
||||
When stdout is a TTY, the CLI pretty-prints results with consola. When piped or when `--json` is set, it outputs raw JSON to stdout — suitable for `jq` or other tools.
|
||||
|
||||
## Commands
|
||||
|
||||
### `emdash init`
|
||||
|
||||
Initialize the database with core schema and optional template data.
|
||||
|
||||
```bash
|
||||
npx emdash init [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ------------ | ----- | ---------------------- | ----------------- |
|
||||
| `--database` | `-d` | Database file path | `./data.db` |
|
||||
| `--cwd` | | Working directory | Current directory |
|
||||
| `--force` | `-f` | Re-run schema and seed | `false` |
|
||||
|
||||
#### Behavior
|
||||
|
||||
1. Reads `emdash` config from `package.json`
|
||||
2. Creates the database file if needed
|
||||
3. Runs core migrations (creates system tables)
|
||||
4. Runs template `schema.sql` if configured
|
||||
5. Runs template `seed.sql` if configured
|
||||
|
||||
<Aside>
|
||||
If the database already contains collections, `init` skips schema and seed unless `--force` is
|
||||
used.
|
||||
</Aside>
|
||||
|
||||
### `emdash dev`
|
||||
|
||||
Start the development server with automatic database setup.
|
||||
|
||||
```bash
|
||||
npx emdash dev [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ------------ | ----- | ---------------------------------------- | ----------------- |
|
||||
| `--database` | `-d` | Database file path | `./data.db` |
|
||||
| `--types` | `-t` | Generate types from remote before starting | `false` |
|
||||
| `--port` | `-p` | Dev server port | `4321` |
|
||||
| `--cwd` | | Working directory | Current directory |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
npx emdash dev
|
||||
|
||||
# Custom port
|
||||
npx emdash dev --port 3000
|
||||
|
||||
# Generate types from remote before starting
|
||||
npx emdash dev --types
|
||||
```
|
||||
|
||||
#### Behavior
|
||||
|
||||
1. Checks for and runs pending database migrations
|
||||
2. If `--types` is set, generates TypeScript types from a remote instance (URL from `EMDASH_URL` env or `emdash.url` in `package.json`)
|
||||
3. Starts Astro dev server with `EMDASH_DATABASE_URL` set
|
||||
|
||||
### `emdash types`
|
||||
|
||||
Generate TypeScript types from a running EmDash instance's schema.
|
||||
|
||||
```bash
|
||||
npx emdash types [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ---------- | ----- | ------------------------ | -------------------- |
|
||||
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
|
||||
| `--token` | `-t` | Auth token | From env/stored creds |
|
||||
| `--output` | `-o` | Output path for types | `.emdash/types.ts` |
|
||||
| `--cwd` | | Working directory | Current directory |
|
||||
|
||||
#### Examples
|
||||
|
||||
```bash
|
||||
# Generate types from local dev server
|
||||
npx emdash types
|
||||
|
||||
# Generate from remote instance
|
||||
npx emdash types --url https://my-site.pages.dev
|
||||
|
||||
# Custom output path
|
||||
npx emdash types --output src/types/emdash.ts
|
||||
```
|
||||
|
||||
#### Behavior
|
||||
|
||||
1. Fetches the schema from the instance
|
||||
2. Generates TypeScript type definitions
|
||||
3. Writes types to the output file
|
||||
4. Writes `schema.json` alongside for reference
|
||||
|
||||
### `emdash login`
|
||||
|
||||
Log in to an EmDash instance using OAuth Device Flow.
|
||||
|
||||
```bash
|
||||
npx emdash login [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ------- | ----- | --------------------- | ---------------------- |
|
||||
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
|
||||
|
||||
#### Behavior
|
||||
|
||||
1. Discovers auth endpoints from the instance
|
||||
2. If localhost and no auth configured, uses dev bypass automatically
|
||||
3. Otherwise initiates OAuth Device Flow — displays a code and opens your browser
|
||||
4. Polls for authorization, then saves credentials to `~/.config/emdash/auth.json`
|
||||
|
||||
Saved credentials are used automatically by all subsequent commands targeting the same instance.
|
||||
|
||||
### `emdash logout`
|
||||
|
||||
Log out and remove stored credentials.
|
||||
|
||||
```bash
|
||||
npx emdash logout [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ------- | ----- | --------------------- | ---------------------- |
|
||||
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
|
||||
|
||||
### `emdash whoami`
|
||||
|
||||
Show the current authenticated user.
|
||||
|
||||
```bash
|
||||
npx emdash whoami [options]
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| --------- | ----- | --------------------- | ---------------------- |
|
||||
| `--url` | `-u` | EmDash instance URL | `http://localhost:4321` |
|
||||
| `--token` | `-t` | Auth token | From env/stored creds |
|
||||
| `--json` | | Output as JSON | |
|
||||
|
||||
Displays email, name, role, auth method, and instance URL.
|
||||
|
||||
### `emdash content`
|
||||
|
||||
Manage content items. All subcommands use the remote API via `EmDashClient`.
|
||||
|
||||
#### `content list <collection>`
|
||||
|
||||
```bash
|
||||
npx emdash content list posts
|
||||
npx emdash content list posts --status published --limit 10
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | -------------------- |
|
||||
| `--status` | Filter by status |
|
||||
| `--limit` | Maximum items |
|
||||
| `--cursor` | Pagination cursor |
|
||||
|
||||
#### `content get <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content get posts 01ABC123
|
||||
npx emdash content get posts 01ABC123 --raw
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------- | ------------------------------------------------ |
|
||||
| `--raw` | Return raw Portable Text (skip markdown conversion) |
|
||||
|
||||
The response includes a `_rev` token — pass it to `content update` to prove you've seen what you're overwriting.
|
||||
|
||||
#### `content create <collection>`
|
||||
|
||||
```bash
|
||||
npx emdash content create posts --data '{"title": "Hello"}'
|
||||
npx emdash content create posts --file post.json --slug hello-world
|
||||
cat post.json | npx emdash content create posts --stdin
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ------------------------------ |
|
||||
| `--data` | JSON string with content data |
|
||||
| `--file` | Read data from a JSON file |
|
||||
| `--stdin` | Read data from stdin |
|
||||
| `--slug` | Content slug |
|
||||
| `--status` | Initial status (draft, published) |
|
||||
|
||||
Provide data via exactly one of `--data`, `--file`, or `--stdin`.
|
||||
|
||||
#### `content update <collection> <id>`
|
||||
|
||||
Like a file editor that requires you to read before you write — you must provide the `_rev` token from a prior `get` to prove you've seen the current state. This prevents accidentally overwriting changes you haven't seen.
|
||||
|
||||
```bash
|
||||
# 1. Read the item, note the _rev
|
||||
npx emdash content get posts 01ABC123
|
||||
|
||||
# 2. Update with the _rev from step 1
|
||||
npx emdash content update posts 01ABC123 \
|
||||
--rev MToyMDI2LTAyLTE0... \
|
||||
--data '{"title": "Updated"}'
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| -------- | -------------------------------------- |
|
||||
| `--rev` | Revision token from `get` (required) |
|
||||
| `--data` | JSON string with content data |
|
||||
| `--file` | Read data from a JSON file |
|
||||
|
||||
If the item has changed since your `get`, the server returns 409 Conflict — re-read and try again.
|
||||
|
||||
#### `content delete <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content delete posts 01ABC123
|
||||
```
|
||||
|
||||
Soft-deletes the content item (moves to trash).
|
||||
|
||||
#### `content publish <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content publish posts 01ABC123
|
||||
```
|
||||
|
||||
#### `content unpublish <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content unpublish posts 01ABC123
|
||||
```
|
||||
|
||||
#### `content schedule <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content schedule posts 01ABC123 --at 2026-03-01T09:00:00Z
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------ | ------------------------------- |
|
||||
| `--at` | ISO 8601 datetime (required) |
|
||||
|
||||
#### `content restore <collection> <id>`
|
||||
|
||||
```bash
|
||||
npx emdash content restore posts 01ABC123
|
||||
```
|
||||
|
||||
Restores a trashed content item.
|
||||
|
||||
### `emdash schema`
|
||||
|
||||
Manage collections and fields.
|
||||
|
||||
#### `schema list`
|
||||
|
||||
```bash
|
||||
npx emdash schema list
|
||||
```
|
||||
|
||||
Lists all collections.
|
||||
|
||||
#### `schema get <collection>`
|
||||
|
||||
```bash
|
||||
npx emdash schema get posts
|
||||
```
|
||||
|
||||
Shows a collection with all its fields.
|
||||
|
||||
#### `schema create <collection>`
|
||||
|
||||
```bash
|
||||
npx emdash schema create articles --label Articles
|
||||
npx emdash schema create articles --label Articles --label-singular Article --description "Blog articles"
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------------ | ------------------------------- |
|
||||
| `--label` | Collection label (required) |
|
||||
| `--label-singular` | Singular label |
|
||||
| `--description` | Collection description |
|
||||
|
||||
#### `schema delete <collection>`
|
||||
|
||||
```bash
|
||||
npx emdash schema delete articles
|
||||
npx emdash schema delete articles --force
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| --------- | ------------------- |
|
||||
| `--force` | Skip confirmation |
|
||||
|
||||
Prompts for confirmation unless `--force` is set.
|
||||
|
||||
#### `schema add-field <collection> <field>`
|
||||
|
||||
```bash
|
||||
npx emdash schema add-field posts body --type portableText --label "Body Content"
|
||||
npx emdash schema add-field posts featured --type boolean --required
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| `--type` | Field type: string, text, number, integer, boolean, datetime, image, reference, portableText, json (required) |
|
||||
| `--label` | Field label (defaults to field slug) |
|
||||
| `--required` | Whether the field is required |
|
||||
|
||||
#### `schema remove-field <collection> <field>`
|
||||
|
||||
```bash
|
||||
npx emdash schema remove-field posts featured
|
||||
```
|
||||
|
||||
### `emdash media`
|
||||
|
||||
Manage media items.
|
||||
|
||||
#### `media list`
|
||||
|
||||
```bash
|
||||
npx emdash media list
|
||||
npx emdash media list --mime image/png --limit 20
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ------------------------ |
|
||||
| `--mime` | Filter by MIME type |
|
||||
| `--limit` | Number of items |
|
||||
| `--cursor` | Pagination cursor |
|
||||
|
||||
#### `media upload <file>`
|
||||
|
||||
```bash
|
||||
npx emdash media upload ./photo.jpg
|
||||
npx emdash media upload ./photo.jpg --alt "A sunset" --caption "Taken in Bristol"
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | -------------- |
|
||||
| `--alt` | Alt text |
|
||||
| `--caption` | Caption text |
|
||||
|
||||
#### `media get <id>`
|
||||
|
||||
```bash
|
||||
npx emdash media get 01MEDIA123
|
||||
```
|
||||
|
||||
#### `media delete <id>`
|
||||
|
||||
```bash
|
||||
npx emdash media delete 01MEDIA123
|
||||
```
|
||||
|
||||
### `emdash search`
|
||||
|
||||
Full-text search across content.
|
||||
|
||||
```bash
|
||||
npx emdash search "hello world"
|
||||
npx emdash search "hello" --collection posts --limit 5
|
||||
```
|
||||
|
||||
| Option | Alias | Description |
|
||||
| -------------- | ----- | -------------------- |
|
||||
| `--collection` | `-c` | Filter by collection |
|
||||
| `--limit` | `-l` | Maximum results |
|
||||
|
||||
### `emdash taxonomy`
|
||||
|
||||
Manage taxonomies and terms.
|
||||
|
||||
#### `taxonomy list`
|
||||
|
||||
```bash
|
||||
npx emdash taxonomy list
|
||||
```
|
||||
|
||||
#### `taxonomy terms <name>`
|
||||
|
||||
```bash
|
||||
npx emdash taxonomy terms categories
|
||||
npx emdash taxonomy terms tags --limit 50
|
||||
```
|
||||
|
||||
| Option | Alias | Description |
|
||||
| ---------- | ----- | ----------------- |
|
||||
| `--limit` | `-l` | Maximum terms |
|
||||
| `--cursor` | | Pagination cursor |
|
||||
|
||||
#### `taxonomy add-term <taxonomy>`
|
||||
|
||||
```bash
|
||||
npx emdash taxonomy add-term categories --name "Tech" --slug tech
|
||||
npx emdash taxonomy add-term categories --name "Frontend" --parent 01PARENT123
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ---------- | ---------------------------------------- |
|
||||
| `--name` | Term label (required) |
|
||||
| `--slug` | Term slug (defaults to slugified name) |
|
||||
| `--parent` | Parent term ID (for hierarchical taxonomies) |
|
||||
|
||||
### `emdash menu`
|
||||
|
||||
Manage navigation menus.
|
||||
|
||||
#### `menu list`
|
||||
|
||||
```bash
|
||||
npx emdash menu list
|
||||
```
|
||||
|
||||
#### `menu get <name>`
|
||||
|
||||
```bash
|
||||
npx emdash menu get primary
|
||||
```
|
||||
|
||||
Returns the menu with all its items.
|
||||
|
||||
### `emdash seed`
|
||||
|
||||
Apply a seed file to the database. This command works directly on a local SQLite file (no running server needed).
|
||||
|
||||
```bash
|
||||
npx emdash seed [path] [options]
|
||||
```
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Argument | Description | Default |
|
||||
| -------- | ----------------- | --------------------- |
|
||||
| `path` | Path to seed file | `.emdash/seed.json` |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ------------------ | ----- | --------------------------------------- | --------------------------- |
|
||||
| `--database` | `-d` | Database file path | `./data.db` |
|
||||
| `--cwd` | | Working directory | Current directory |
|
||||
| `--validate` | | Validate only, don't apply | `false` |
|
||||
| `--no-content` | | Skip sample content | `false` |
|
||||
| `--on-conflict` | | Conflict handling: skip, update, error | `skip` |
|
||||
| `--uploads-dir` | | Directory for media uploads | `.emdash/uploads` |
|
||||
| `--media-base-url` | | Base URL for media files | `/_emdash/api/media/file` |
|
||||
| `--base-url` | | Site base URL (for absolute media URLs) | |
|
||||
|
||||
#### Seed File Resolution
|
||||
|
||||
The command looks for seed files in this order:
|
||||
|
||||
1. Positional argument (if provided)
|
||||
2. `.emdash/seed.json` (convention)
|
||||
3. Path from `package.json` `emdash.seed` field
|
||||
|
||||
### `emdash export-seed`
|
||||
|
||||
Export database schema and content as a seed file. Works directly on a local SQLite file.
|
||||
|
||||
```bash
|
||||
npx emdash export-seed [options] > seed.json
|
||||
```
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Alias | Description | Default |
|
||||
| ---------------- | ----- | ---------------------------------------------------- | ----------------- |
|
||||
| `--database` | `-d` | Database file path | `./data.db` |
|
||||
| `--cwd` | | Working directory | Current directory |
|
||||
| `--with-content` | | Include content (all or comma-separated collections) | |
|
||||
| `--no-pretty` | | Disable JSON formatting | `false` |
|
||||
|
||||
#### Output Format
|
||||
|
||||
The exported seed file includes:
|
||||
|
||||
- **Settings**: Site title, tagline, social links
|
||||
- **Collections**: All collection definitions with fields
|
||||
- **Taxonomies**: Taxonomy definitions and terms
|
||||
- **Menus**: Navigation menus with items
|
||||
- **Widget Areas**: Widget areas and widgets
|
||||
- **Content** (if requested): Entries with `$media` references and `$ref:` syntax for portability
|
||||
|
||||
### `emdash secrets generate`
|
||||
|
||||
Generate an `EMDASH_ENCRYPTION_KEY` for your deployment. The key is used to
|
||||
encrypt plugin secrets at rest.
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
```
|
||||
|
||||
Prints the new key to stdout. Pipe it into your secret store, or write it
|
||||
straight to a dev file with `--write`:
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate --write .dev.vars
|
||||
npx emdash secrets generate --write .env
|
||||
```
|
||||
|
||||
`--write` refuses to overwrite an existing entry without `--force`.
|
||||
Replacing a key in a deployment with existing encrypted data will leave
|
||||
those secrets unreadable, so the protection is intentional.
|
||||
|
||||
### `emdash secrets fingerprint <key>`
|
||||
|
||||
Print the 8-character fingerprint (kid) of a key without exposing its
|
||||
value. Useful in CI for verifying the right key was deployed:
|
||||
|
||||
```bash
|
||||
npx emdash secrets fingerprint emdash_enc_v1_...
|
||||
```
|
||||
|
||||
## Generated Files
|
||||
|
||||
### `.emdash/types.ts`
|
||||
|
||||
TypeScript interfaces generated by `emdash types`:
|
||||
|
||||
```ts
|
||||
// Generated by EmDash CLI
|
||||
// Do not edit manually - run `emdash types` to regenerate
|
||||
|
||||
import type { PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
title: string;
|
||||
content: PortableTextBlock[];
|
||||
publishedAt: Date | null;
|
||||
}
|
||||
```
|
||||
|
||||
### `.emdash/schema.json`
|
||||
|
||||
Raw schema export for tooling:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "a1b2c3d4",
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"fields": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `EMDASH_DATABASE_URL` | Database URL (set automatically by `dev`) |
|
||||
| `EMDASH_TOKEN` | Auth token for remote operations |
|
||||
| `EMDASH_URL` | Default remote URL for `types` and `dev --types` |
|
||||
| `EMDASH_ENCRYPTION_KEY` | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. Generate with `emdash secrets generate`. |
|
||||
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, EmDash generates and persists one in the options table. |
|
||||
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, EmDash generates and persists one in the options table. |
|
||||
| `EMDASH_AUTH_SECRET` | Legacy. Used as the IP-salt source if set, so existing installs keep stable commenter-IP hashes across upgrade. New installs should not set this. |
|
||||
|
||||
## Package Scripts
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev": "emdash dev",
|
||||
"init": "emdash init",
|
||||
"types": "emdash types",
|
||||
"seed": "emdash seed",
|
||||
"export-seed": "emdash export-seed",
|
||||
"db:reset": "rm -f data.db && emdash init"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ---------------------------------------- |
|
||||
| `0` | Success |
|
||||
| `1` | Error (configuration, network, database) |
|
||||
646
docs/src/content/docs/reference/configuration.mdx
Normal file
@@ -0,0 +1,646 @@
|
||||
---
|
||||
title: Configuration Reference
|
||||
description: Complete reference for EmDash configuration options.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is configured through two files: `astro.config.mjs` for the integration and `src/live.config.ts` for content collections.
|
||||
|
||||
## Astro Integration
|
||||
|
||||
Configure EmDash as an Astro integration:
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local, r2, s3 } from "emdash/astro";
|
||||
import { sqlite, libsql, d1 } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
plugins: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
## Integration Options
|
||||
|
||||
### `database`
|
||||
|
||||
**Required.** Database adapter configuration.
|
||||
|
||||
```js
|
||||
// SQLite (Node.js)
|
||||
database: sqlite({ url: "file:./data.db" });
|
||||
|
||||
// PostgreSQL
|
||||
database: postgres({ connectionString: process.env.DATABASE_URL });
|
||||
|
||||
// libSQL
|
||||
database: libsql({
|
||||
url: process.env.LIBSQL_DATABASE_URL,
|
||||
authToken: process.env.LIBSQL_AUTH_TOKEN,
|
||||
});
|
||||
|
||||
// Cloudflare D1 (import from @emdash-cms/cloudflare)
|
||||
database: d1({ binding: "DB" });
|
||||
```
|
||||
|
||||
See [Database Options](/deployment/database/) for details.
|
||||
|
||||
### `storage`
|
||||
|
||||
**Required.** Media storage adapter configuration.
|
||||
|
||||
```js
|
||||
// Local filesystem (development)
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
});
|
||||
|
||||
// R2 binding (Cloudflare Workers)
|
||||
storage: r2({
|
||||
binding: "MEDIA",
|
||||
publicUrl: "https://pub-xxxx.r2.dev", // optional
|
||||
});
|
||||
|
||||
// S3-compatible (any platform) — all fields from S3_* environment variables
|
||||
storage: s3()
|
||||
|
||||
// Or with explicit values
|
||||
storage: s3({
|
||||
endpoint: "https://s3.amazonaws.com",
|
||||
bucket: "my-bucket",
|
||||
accessKeyId: process.env.S3_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
|
||||
region: "us-east-1", // optional, default: "auto"
|
||||
publicUrl: "https://cdn.example.com", // optional
|
||||
});
|
||||
```
|
||||
|
||||
See [Storage Options](/deployment/storage/) for details.
|
||||
|
||||
### `plugins`
|
||||
|
||||
**Optional.** Array of EmDash plugins.
|
||||
|
||||
```js
|
||||
import seoPlugin from "@emdash-cms/plugin-seo";
|
||||
|
||||
plugins: [seoPlugin()];
|
||||
```
|
||||
|
||||
### `fonts`
|
||||
|
||||
**Optional.** Admin UI font configuration.
|
||||
|
||||
By default, EmDash loads [Noto Sans](https://fonts.google.com/noto/specimen/Noto+Sans) via the [Astro Font API](https://docs.astro.build/en/guides/fonts/). Fonts are downloaded from Google at build time and self-hosted, so there are no runtime CDN requests. The base font covers Latin, Cyrillic, Greek, Devanagari, and Vietnamese scripts.
|
||||
|
||||
To add support for additional writing systems, pass script names:
|
||||
|
||||
```js
|
||||
emdash({
|
||||
fonts: {
|
||||
scripts: ["arabic", "japanese"],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Available scripts: `arabic`, `armenian`, `bengali`, `chinese-simplified`, `chinese-traditional`, `chinese-hongkong`, `devanagari`, `ethiopic`, `georgian`, `gujarati`, `gurmukhi`, `hebrew`, `japanese`, `kannada`, `khmer`, `korean`, `lao`, `malayalam`, `myanmar`, `oriya`, `sinhala`, `tamil`, `telugu`, `thai`, `tibetan`.
|
||||
|
||||
Each script maps to the corresponding Noto Sans variant on Google Fonts (e.g. `"arabic"` loads Noto Sans Arabic). All font faces share a single `font-family` name and use `unicode-range` so the browser only downloads the files it needs for the characters on the page.
|
||||
|
||||
Set to `false` to disable font injection entirely and use system fonts:
|
||||
|
||||
```js
|
||||
emdash({
|
||||
fonts: false,
|
||||
})
|
||||
```
|
||||
|
||||
The admin CSS uses the `--font-emdash` CSS variable. This is set automatically by the font configuration above.
|
||||
|
||||
### `auth`
|
||||
|
||||
**Optional.** Authentication configuration.
|
||||
|
||||
```js
|
||||
import { github } from "emdash/auth/providers/github";
|
||||
import { google } from "emdash/auth/providers/google";
|
||||
import { atproto } from "@emdash-cms/auth-atproto";
|
||||
|
||||
emdash({
|
||||
auth: {
|
||||
// Self-signup configuration
|
||||
selfSignup: {
|
||||
domains: ["example.com"],
|
||||
defaultRole: 20, // Contributor
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
maxAge: 30 * 24 * 60 * 60, // 30 days
|
||||
sliding: true, // Reset expiry on activity
|
||||
},
|
||||
|
||||
// OR use Cloudflare Access (exclusive mode)
|
||||
cloudflareAccess: {
|
||||
teamDomain: "myteam.cloudflareaccess.com",
|
||||
audience: "your-app-audience-tag",
|
||||
autoProvision: true,
|
||||
defaultRole: 30,
|
||||
syncRoles: false,
|
||||
roleMapping: {
|
||||
Admins: 50,
|
||||
Editors: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Pluggable login providers (top-level, not nested under `auth`)
|
||||
authProviders: [github(), google(), atproto()],
|
||||
});
|
||||
```
|
||||
|
||||
#### `auth.selfSignup`
|
||||
|
||||
Allow users to self-register if their email domain is allowed.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------- | ---------- | ------- | ------------------------- |
|
||||
| `domains` | `string[]` | `[]` | Allowed email domains |
|
||||
| `defaultRole` | `number` | `20` | Role for self-signups |
|
||||
|
||||
```js
|
||||
selfSignup: {
|
||||
domains: ["example.com", "acme.org"],
|
||||
defaultRole: 20, // Contributor
|
||||
}
|
||||
```
|
||||
|
||||
### `authProviders`
|
||||
|
||||
**Optional.** An array of pluggable login providers (top-level, alongside `auth`). Each entry is the result of calling a provider factory:
|
||||
|
||||
```js
|
||||
import { github } from "emdash/auth/providers/github";
|
||||
import { google } from "emdash/auth/providers/google";
|
||||
import { atproto } from "@emdash-cms/auth-atproto";
|
||||
|
||||
emdash({
|
||||
authProviders: [github(), google(), atproto()],
|
||||
});
|
||||
```
|
||||
|
||||
Built-in providers:
|
||||
|
||||
- `github()` — reads `EMDASH_OAUTH_GITHUB_CLIENT_ID` / `EMDASH_OAUTH_GITHUB_CLIENT_SECRET` (or unprefixed fallbacks).
|
||||
- `google()` — reads `EMDASH_OAUTH_GOOGLE_CLIENT_ID` / `EMDASH_OAUTH_GOOGLE_CLIENT_SECRET`.
|
||||
- `atproto()` — Atmosphere/AT Protocol login. No env vars needed. Accepts `{ allowedDIDs, allowedHandles, defaultRole }`. See the [Atmosphere login guide](/guides/atmosphere-auth/).
|
||||
|
||||
Third-party packages can register their own providers using the same `AuthProviderDescriptor` shape — see [Login Providers](/guides/authentication/#login-providers).
|
||||
|
||||
#### `auth.session`
|
||||
|
||||
Session configuration.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------- | --------- | ----------------- | ------------------------------ |
|
||||
| `maxAge` | `number` | `2592000` (30d) | Session lifetime in seconds |
|
||||
| `sliding` | `boolean` | `true` | Reset expiry on activity |
|
||||
|
||||
#### `auth.cloudflareAccess`
|
||||
|
||||
Use Cloudflare Access as the authentication provider instead of passkeys.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | --------- | -------- | ------------------------------ |
|
||||
| `teamDomain` | `string` | required | Your Access team domain |
|
||||
| `audience` | `string` | required | Application Audience (AUD) tag |
|
||||
| `autoProvision` | `boolean` | `true` | Create users on first login |
|
||||
| `defaultRole` | `number` | `30` | Default role for new users |
|
||||
| `syncRoles` | `boolean` | `false` | Update role on each login |
|
||||
| `roleMapping` | `object` | — | Map IdP groups to roles |
|
||||
|
||||
<Aside>
|
||||
When `cloudflareAccess` is configured, it becomes the exclusive auth method. Passkeys, OAuth,
|
||||
magic links, and self-signup are disabled.
|
||||
</Aside>
|
||||
|
||||
### `siteUrl`
|
||||
|
||||
**Optional.** The public browser-facing origin for the site (scheme + host + optional port, **no path**).
|
||||
|
||||
Behind a **TLS-terminating reverse proxy**, `Astro.url` returns the internal address (`http://localhost:4321`) instead of the public one (`https://cms.example.com`). This breaks passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. Set `siteUrl` to fix all of these at once.
|
||||
|
||||
The integration **validates** this value at load time: it must be a valid URL with **`http:`** or **`https:`** protocol and is normalized to **origin** (path is stripped).
|
||||
|
||||
```js
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
siteUrl: "https://cms.example.com",
|
||||
});
|
||||
```
|
||||
|
||||
When `siteUrl` is not set in config, EmDash checks environment variables in order: `EMDASH_SITE_URL`, then `SITE_URL`. This is useful for container deployments where the public URL is set at runtime.
|
||||
|
||||
<Aside type="tip">
|
||||
`siteUrl` replaces `passkeyPublicOrigin` (removed). If you were using `passkeyPublicOrigin`, rename it to `siteUrl` -- it now covers passkeys and all other origin-dependent features.
|
||||
</Aside>
|
||||
|
||||
#### Multi-origin passkey verification
|
||||
|
||||
`siteUrl` defines a single canonical origin. When the same EmDash deployment is reachable under several hostnames that share a registrable parent domain (e.g. `https://example.com` and `https://preview.example.com`), passkey verification rejects assertions whose origin doesn't match `siteUrl` exactly -- even though WebAuthn allows passkeys to be valid across subdomains under the same `rpId`.
|
||||
|
||||
Declare additional accepted origins via either `allowedOrigins` in `astro.config.mjs` or the `EMDASH_ALLOWED_ORIGINS` env var. The canonical `siteUrl` remains the source of `rpId`; entries listed here are accepted at verification time. The two sources are merged at runtime, so config can declare the stable origins (versioned, code-reviewed) while env adds environment-specific extras (e.g. ephemeral PR previews).
|
||||
|
||||
```js title="astro.config.mjs"
|
||||
emdash({
|
||||
siteUrl: "https://example.com",
|
||||
allowedOrigins: ["https://preview.example.com"],
|
||||
})
|
||||
```
|
||||
|
||||
```bash title=".env / wrangler.jsonc / Docker env"
|
||||
EMDASH_SITE_URL=https://example.com
|
||||
EMDASH_ALLOWED_ORIGINS=https://preview.example.com,https://staging.example.com
|
||||
```
|
||||
|
||||
##### Validation
|
||||
|
||||
EmDash validates these to prevent dead config the browser would never honor:
|
||||
|
||||
- Each entry must be a parseable `http:` or `https:` URL with no trailing dot and no empty labels in the hostname.
|
||||
- When `allowedOrigins` is non-empty, `siteUrl` must be set (either source) and must not be an IP literal or have a trailing-dot hostname.
|
||||
- Each origin must be the same hostname as `siteUrl` or a subdomain of it. (WebAuthn requires `rpId` to be a registrable suffix of every origin.)
|
||||
|
||||
When validation fails, you'll see a source-attributed error like `EmDash config error in EMDASH_ALLOWED_ORIGINS: "https://other-site.com" is not a subdomain of siteUrl "https://example.com". Allowed origins must be the same hostname as siteUrl or a subdomain of it.`
|
||||
|
||||
Where the error surfaces depends on where the values are declared:
|
||||
|
||||
- **At Astro startup**, when both `config.allowedOrigins` and `config.siteUrl` come from `astro.config.mjs` -- typos in code fail the build.
|
||||
- **At first passkey verification**, when either value comes from `EMDASH_ALLOWED_ORIGINS` or `EMDASH_SITE_URL` -- env mismatches surface as 500s on the first verify attempt.
|
||||
|
||||
#### Reverse proxy setup
|
||||
|
||||
Astro only reflects **`X-Forwarded-*`** when the public host is allowed. Configure [**`security.allowedDomains`**](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) for the hostname (and schemes) your users hit. In **`astro dev`**, add matching **`vite.server.allowedHosts`** so Vite accepts the proxy **`Host`** header.
|
||||
|
||||
Prefer fixing **`allowedDomains`** (and forwarded headers) first; use **`siteUrl`** when the reconstructed URL **still** diverges from the browser origin (typical when TLS is terminated in front and the upstream request stays **`http://`**).
|
||||
|
||||
With TLS in front, binding the dev server to loopback (**`astro dev --host 127.0.0.1`**) is often enough: the proxy connects locally while **`siteUrl`** matches the public HTTPS origin.
|
||||
|
||||
If your proxy writes a client-IP header, set [**`trustedProxyHeaders`**](#trustedproxyheaders) so EmDash's rate limits can use the real client IP instead of bucketing every request under a shared "unknown" key.
|
||||
|
||||
<Aside type="caution">
|
||||
Your reverse proxy should forward a **port-aware** `Host` / `X-Forwarded-Host` when you use non-default ports. If the proxy strips the port, **`rpId`** and Astro’s rebuilt URL can be wrong.
|
||||
</Aside>
|
||||
|
||||
```js title="astro.config.mjs (excerpt)"
|
||||
import { defineConfig } from "astro/config";
|
||||
import emdash, { local } from "emdash/astro";
|
||||
import { sqlite } from "emdash/db";
|
||||
|
||||
export default defineConfig({
|
||||
security: {
|
||||
allowedDomains: [
|
||||
{ hostname: "cms.example.com", protocol: "https" },
|
||||
{ hostname: "cms.example.com", protocol: "http" },
|
||||
],
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
allowedHosts: ["cms.example.com"],
|
||||
},
|
||||
},
|
||||
integrations: [
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
siteUrl: "https://cms.example.com",
|
||||
}),
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
### `trustedProxyHeaders`
|
||||
|
||||
**Optional.** Headers to trust for client-IP resolution when running behind a reverse proxy you control. Used by auth rate limits (magic-link, signup, passkey, OAuth device flow) and the public comment endpoint.
|
||||
|
||||
On Cloudflare the `cf` object attached to the request is used automatically — you normally do **not** need to set this. On self-hosted deployments behind nginx, Caddy, Traefik, Fly, Railway, or similar, set this to the header your proxy writes so rate limits can bucket by real client IP instead of treating every request as "unknown".
|
||||
|
||||
```js
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
// nginx / Caddy / Traefik
|
||||
trustedProxyHeaders: ["x-real-ip"],
|
||||
});
|
||||
```
|
||||
|
||||
Headers are tried in order. Values matching `*-forwarded-for` are parsed as comma-separated lists and the first entry is used.
|
||||
|
||||
```js
|
||||
emdash({
|
||||
// Fly.io, falling back to generic X-Forwarded-For
|
||||
trustedProxyHeaders: ["fly-client-ip", "x-forwarded-for"],
|
||||
});
|
||||
```
|
||||
|
||||
When not set in config, EmDash reads the `EMDASH_TRUSTED_PROXY_HEADERS` env var (comma-separated). An explicit empty array in config overrides the env var.
|
||||
|
||||
<Aside type="caution">
|
||||
**Only set this when you control the reverse proxy.** Untrusted clients can set any header
|
||||
they like; trusting forwarded-IP headers from an open network is an IP-spoofing vulnerability
|
||||
that defeats rate limiting. If EmDash is exposed directly to the public internet with no
|
||||
proxy in front, leave this unset — rate limits will fall back to a shared "unknown" bucket
|
||||
(stricter defaults) rather than trust a spoofable header.
|
||||
</Aside>
|
||||
|
||||
### `maxUploadSize`
|
||||
|
||||
**Optional.** Maximum allowed media file upload size in bytes. Applies to both direct multipart uploads and signed-URL uploads. Defaults to `52_428_800` (50 MB).
|
||||
|
||||
```js
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
}),
|
||||
maxUploadSize: 100 * 1024 * 1024, // 100 MB
|
||||
});
|
||||
```
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| `number` (bytes) | Must be a positive finite integer |
|
||||
| omitted | Defaults to 50 MB |
|
||||
|
||||
Uploads that exceed the configured limit are rejected with a `413 Payload Too Large` response on the direct upload path, or a `400 Validation Error` on the signed-URL path.
|
||||
|
||||
## Database Adapters
|
||||
|
||||
Import from `emdash/db`:
|
||||
|
||||
```js
|
||||
import { sqlite, libsql, postgres, d1 } from "emdash/db";
|
||||
```
|
||||
|
||||
### `sqlite(config)`
|
||||
|
||||
SQLite database using better-sqlite3.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------ | -------- | ----------------------------- |
|
||||
| `url` | `string` | File path with `file:` prefix |
|
||||
|
||||
```js
|
||||
sqlite({ url: "file:./data.db" });
|
||||
```
|
||||
|
||||
### `libsql(config)`
|
||||
|
||||
libSQL database.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | ------------------------------------- |
|
||||
| `url` | `string` | Database URL |
|
||||
| `authToken` | `string` | Auth token (optional for local files) |
|
||||
|
||||
```js
|
||||
libsql({
|
||||
url: process.env.LIBSQL_DATABASE_URL,
|
||||
authToken: process.env.LIBSQL_AUTH_TOKEN,
|
||||
});
|
||||
```
|
||||
|
||||
### `postgres(config)`
|
||||
|
||||
PostgreSQL database with connection pooling.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ------------------ | --------- | ------------------------------------ |
|
||||
| `connectionString` | `string` | PostgreSQL connection URL |
|
||||
| `host` | `string` | Database host |
|
||||
| `port` | `number` | Database port |
|
||||
| `database` | `string` | Database name |
|
||||
| `user` | `string` | Database user |
|
||||
| `password` | `string` | Database password |
|
||||
| `ssl` | `boolean` | Enable SSL |
|
||||
| `pool.min` | `number` | Minimum pool size (default: 0) |
|
||||
| `pool.max` | `number` | Maximum pool size (default: 10) |
|
||||
|
||||
```js
|
||||
postgres({ connectionString: process.env.DATABASE_URL });
|
||||
```
|
||||
|
||||
### `d1(config)`
|
||||
|
||||
Cloudflare D1 database. Import from `@emdash-cms/cloudflare`.
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | -------- | -------------------- | --------------------------------------------------- |
|
||||
| `binding` | `string` | — | D1 binding name from `wrangler.jsonc` |
|
||||
| `session` | `string` | `"disabled"` | Read replication mode: `"disabled"`, `"auto"`, or `"primary-first"` |
|
||||
| `bookmarkCookie` | `string` | `"__em_d1_bookmark"` | Cookie name for session bookmarks |
|
||||
|
||||
```js
|
||||
// Basic
|
||||
d1({ binding: "DB" });
|
||||
|
||||
// With read replicas
|
||||
d1({ binding: "DB", session: "auto" });
|
||||
```
|
||||
|
||||
When `session` is `"auto"` or `"primary-first"`, EmDash uses the D1 Sessions API to route read queries to nearby replicas. Authenticated users get bookmark-based read-your-writes consistency. See [Database Options — Read Replicas](/deployment/database/#read-replicas) for details.
|
||||
|
||||
<Aside type="caution">
|
||||
D1 requires migrations via Wrangler CLI. DDL statements are not allowed at runtime.
|
||||
</Aside>
|
||||
|
||||
## Storage Adapters
|
||||
|
||||
Import from `emdash/astro`:
|
||||
|
||||
```js
|
||||
import emdash, { local, r2, s3 } from "emdash/astro";
|
||||
```
|
||||
|
||||
### `local(config)`
|
||||
|
||||
Local filesystem storage.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | -------------------------- |
|
||||
| `directory` | `string` | Directory path |
|
||||
| `baseUrl` | `string` | Base URL for serving files |
|
||||
|
||||
```js
|
||||
local({
|
||||
directory: "./uploads",
|
||||
baseUrl: "/_emdash/api/media/file",
|
||||
});
|
||||
```
|
||||
|
||||
### `r2(config)`
|
||||
|
||||
Cloudflare R2 binding.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------- | -------- | ------------------- |
|
||||
| `binding` | `string` | R2 binding name |
|
||||
| `publicUrl` | `string` | Optional public URL |
|
||||
|
||||
```js
|
||||
r2({
|
||||
binding: "MEDIA",
|
||||
publicUrl: "https://pub-xxxx.r2.dev",
|
||||
});
|
||||
```
|
||||
|
||||
### `s3(config?)`
|
||||
|
||||
S3-compatible storage. All config fields are optional: any field omitted from
|
||||
`s3({...})` is resolved from the matching `S3_*` environment variable when the
|
||||
Node process starts. Explicit values always take precedence.
|
||||
|
||||
**Prerequisite:** install `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`
|
||||
in your project. EmDash core does not bundle the AWS SDK. See
|
||||
[Storage Options → S3-Compatible Storage](/deployment/storage/#s3-compatible-storage)
|
||||
for details.
|
||||
|
||||
| Option | Type | Description |
|
||||
| ----------------- | -------- | ----------------------------------- |
|
||||
| `endpoint` | `string` | S3 endpoint URL (`S3_ENDPOINT`) |
|
||||
| `bucket` | `string` | Bucket name (`S3_BUCKET`) |
|
||||
| `accessKeyId` | `string` | Access key (`S3_ACCESS_KEY_ID`) |
|
||||
| `secretAccessKey` | `string` | Secret key (`S3_SECRET_ACCESS_KEY`) |
|
||||
| `region` | `string` | Region, default `"auto"` (`S3_REGION`) |
|
||||
| `publicUrl` | `string` | Optional CDN URL (`S3_PUBLIC_URL`) |
|
||||
|
||||
```js
|
||||
// All fields from S3_* environment variables (Node container deployments)
|
||||
s3()
|
||||
|
||||
// Mix: CDN from config, rest from environment
|
||||
s3({ publicUrl: "https://cdn.example.com" })
|
||||
|
||||
// All explicit (unchanged from before)
|
||||
s3({
|
||||
endpoint: "https://xxx.r2.cloudflarestorage.com",
|
||||
bucket: "media",
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||
publicUrl: "https://cdn.example.com",
|
||||
})
|
||||
```
|
||||
|
||||
Runtime environment variable resolution is a Node-only feature. On Cloudflare
|
||||
Workers, secrets and variables are exposed through the `env` parameter of the
|
||||
fetch handler, not through `process.env`, so `S3_*` environment variables are
|
||||
not picked up. Workers deployments should either use the [`r2(config)`](#r2config)
|
||||
adapter or pass explicit values to `s3({...})`. See
|
||||
[Storage Options](/deployment/storage/#s3-compatible-storage) for details.
|
||||
|
||||
## Live Collections
|
||||
|
||||
Configure the EmDash loader in `src/live.config.ts`:
|
||||
|
||||
```ts title="src/live.config.ts"
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({
|
||||
loader: emdashLoader(),
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
### Loader Options
|
||||
|
||||
The `emdashLoader()` function accepts optional configuration:
|
||||
|
||||
```ts
|
||||
emdashLoader({
|
||||
// Currently no options - reserved for future use
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
EmDash respects these environment variables:
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------- |
|
||||
| `EMDASH_SITE_URL` | Public browser-facing origin (falls back to `SITE_URL`) |
|
||||
| `EMDASH_ALLOWED_ORIGINS` | Comma-separated list of additional origins accepted by passkey verification (multi-subdomain deployments). |
|
||||
| `EMDASH_DATABASE_URL` | Override database URL |
|
||||
| `EMDASH_ENCRYPTION_KEY` | Key for encrypting plugin secrets at rest. Operator-provided — never stored in the database. |
|
||||
| `EMDASH_PREVIEW_SECRET` | Optional override for preview HMAC secret. When unset, a stable per-site value is generated and stored in the database. |
|
||||
| `EMDASH_IP_SALT` | Optional override for the commenter-IP hash salt. When unset, a stable per-site value is generated and stored in the database. |
|
||||
| `EMDASH_AUTH_SECRET` | Legacy. Used as the IP-salt source if set; existing installs should keep this to preserve stable commenter-IP hashes across upgrade. |
|
||||
| `EMDASH_URL` | Remote EmDash URL for schema sync |
|
||||
|
||||
Generate an encryption key with:
|
||||
|
||||
```bash
|
||||
npx emdash secrets generate
|
||||
```
|
||||
|
||||
## package.json Configuration
|
||||
|
||||
Optional configuration in `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"emdash": {
|
||||
"label": "My Blog Template",
|
||||
"description": "A clean, minimal blog template",
|
||||
"seed": ".emdash/seed.json",
|
||||
"url": "https://my-site.pages.dev",
|
||||
"preview": "https://emdash-blog.pages.dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ------------- | ---------------------------------- |
|
||||
| `label` | Template name for display |
|
||||
| `description` | Template description |
|
||||
| `seed` | Path to seed JSON file |
|
||||
| `url` | Remote URL for schema sync |
|
||||
| `preview` | Demo site URL for template preview |
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
EmDash generates types in `.emdash/types.ts`. Add to your `tsconfig.json`:
|
||||
|
||||
```json title="tsconfig.json"
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@emdash-cms/types": ["./.emdash/types.ts"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Generate types with:
|
||||
|
||||
```bash
|
||||
npx emdash types
|
||||
```
|
||||
428
docs/src/content/docs/reference/field-types.mdx
Normal file
@@ -0,0 +1,428 @@
|
||||
---
|
||||
title: Field Types Reference
|
||||
description: Complete reference for all EmDash field types.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash supports 14 field types for defining content schemas. Each type maps to a SQLite column type and provides appropriate admin UI.
|
||||
|
||||
## Overview
|
||||
|
||||
| Type | SQLite Column | Description |
|
||||
| -------------- | ------------- | -------------------------- |
|
||||
| `string` | TEXT | Short text input |
|
||||
| `text` | TEXT | Multi-line text |
|
||||
| `number` | REAL | Decimal number |
|
||||
| `integer` | INTEGER | Whole number |
|
||||
| `boolean` | INTEGER | True/false |
|
||||
| `datetime` | TEXT | Date and time |
|
||||
| `select` | TEXT | Single choice from options |
|
||||
| `multiSelect` | JSON | Multiple choices |
|
||||
| `portableText` | JSON | Rich text content |
|
||||
| `image` | TEXT | Image reference |
|
||||
| `file` | TEXT | File reference |
|
||||
| `reference` | TEXT | Reference to another entry |
|
||||
| `json` | JSON | Arbitrary JSON data |
|
||||
| `slug` | TEXT | URL-safe identifier |
|
||||
|
||||
## Text Types
|
||||
|
||||
### `string`
|
||||
|
||||
Short, single-line text. Use for titles, names, and short values.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "title",
|
||||
label: "Title",
|
||||
type: "string",
|
||||
required: true,
|
||||
validation: {
|
||||
minLength: 1,
|
||||
maxLength: 200,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `minLength` — Minimum character count
|
||||
- `maxLength` — Maximum character count
|
||||
- `pattern` — Regex pattern to match
|
||||
|
||||
**Widget options:**
|
||||
|
||||
- None specific
|
||||
|
||||
### `text`
|
||||
|
||||
Multi-line plain text. Use for descriptions, excerpts, and longer plain text.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "excerpt",
|
||||
label: "Excerpt",
|
||||
type: "text",
|
||||
options: {
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `minLength` — Minimum character count
|
||||
- `maxLength` — Maximum character count
|
||||
|
||||
**Widget options:**
|
||||
|
||||
- `rows` — Number of rows in textarea (default: 3)
|
||||
|
||||
### `slug`
|
||||
|
||||
URL-safe identifier. Automatically generated from another field or manually entered.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "slug",
|
||||
label: "URL Slug",
|
||||
type: "slug",
|
||||
required: true,
|
||||
unique: true,
|
||||
}
|
||||
```
|
||||
|
||||
Slugs are automatically sanitized: lowercased, spaces replaced with hyphens, special characters removed.
|
||||
|
||||
## Number Types
|
||||
|
||||
### `number`
|
||||
|
||||
Decimal number. Use for prices, ratings, and measurements.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "price",
|
||||
label: "Price",
|
||||
type: "number",
|
||||
required: true,
|
||||
validation: {
|
||||
min: 0,
|
||||
max: 999999.99,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `min` — Minimum value
|
||||
- `max` — Maximum value
|
||||
|
||||
Stored as SQLite REAL (64-bit floating point).
|
||||
|
||||
### `integer`
|
||||
|
||||
Whole number. Use for quantities, counts, and order values.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "quantity",
|
||||
label: "Quantity",
|
||||
type: "integer",
|
||||
defaultValue: 1,
|
||||
validation: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `min` — Minimum value
|
||||
- `max` — Maximum value
|
||||
|
||||
Stored as SQLite INTEGER.
|
||||
|
||||
### `boolean`
|
||||
|
||||
True or false. Use for toggles and flags.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "featured",
|
||||
label: "Featured",
|
||||
type: "boolean",
|
||||
defaultValue: false,
|
||||
}
|
||||
```
|
||||
|
||||
Stored as SQLite INTEGER (0 or 1).
|
||||
|
||||
## Date and Time
|
||||
|
||||
### `datetime`
|
||||
|
||||
Date and time value. Stored in ISO 8601 format.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "publishedAt",
|
||||
label: "Published At",
|
||||
type: "datetime",
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `min` — Minimum date (ISO string)
|
||||
- `max` — Maximum date (ISO string)
|
||||
|
||||
**Storage format:** `2025-01-24T12:00:00.000Z`
|
||||
|
||||
## Selection Types
|
||||
|
||||
### `select`
|
||||
|
||||
Single selection from predefined options.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "status",
|
||||
label: "Status",
|
||||
type: "select",
|
||||
required: true,
|
||||
defaultValue: "draft",
|
||||
validation: {
|
||||
options: ["draft", "published", "archived"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `options` — Array of allowed values (required)
|
||||
|
||||
Stored as TEXT containing the selected value.
|
||||
|
||||
### `multiSelect`
|
||||
|
||||
Multiple selections from predefined options.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "tags",
|
||||
label: "Tags",
|
||||
type: "multiSelect",
|
||||
validation: {
|
||||
options: ["news", "tutorial", "review", "opinion"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Validation options:**
|
||||
|
||||
- `options` — Array of allowed values (required)
|
||||
|
||||
Stored as JSON array: `["news", "tutorial"]`
|
||||
|
||||
## Rich Content
|
||||
|
||||
### `portableText`
|
||||
|
||||
Rich text content using Portable Text format. Supports headings, lists, links, images, and custom blocks.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "content",
|
||||
label: "Content",
|
||||
type: "portableText",
|
||||
required: true,
|
||||
}
|
||||
```
|
||||
|
||||
Stored as JSON array of Portable Text blocks:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Hello world" }]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Plugins can add custom block types (embeds, widgets, etc.) to the editor. These appear in the slash command menu and are automatically rendered on the site. See [Creating Plugins — Portable Text Block Types](/plugins/creating-plugins/#portable-text-block-types).
|
||||
|
||||
<Aside>
|
||||
Portable Text is a specification for structured rich text. See
|
||||
[portabletext.org](https://portabletext.org) for details.
|
||||
</Aside>
|
||||
|
||||
## Media Types
|
||||
|
||||
### `image`
|
||||
|
||||
Reference to an uploaded image. Includes metadata like dimensions and alt text.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "featuredImage",
|
||||
label: "Featured Image",
|
||||
type: "image",
|
||||
options: {
|
||||
showPreview: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Widget options:**
|
||||
|
||||
- `showPreview` — Show image preview in admin (default: true)
|
||||
|
||||
**Stored value:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXK5MZSN...",
|
||||
"url": "https://cdn.example.com/image.jpg",
|
||||
"alt": "Description",
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
}
|
||||
```
|
||||
|
||||
### `file`
|
||||
|
||||
Reference to an uploaded file (documents, PDFs, etc.).
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "document",
|
||||
label: "Document",
|
||||
type: "file",
|
||||
}
|
||||
```
|
||||
|
||||
**Stored value:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "01HXK5MZSN...",
|
||||
"url": "https://cdn.example.com/doc.pdf",
|
||||
"filename": "report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"size": 102400
|
||||
}
|
||||
```
|
||||
|
||||
## Relational Types
|
||||
|
||||
### `reference`
|
||||
|
||||
Reference to another content entry.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "author",
|
||||
label: "Author",
|
||||
type: "reference",
|
||||
required: true,
|
||||
options: {
|
||||
collection: "authors",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**Widget options:**
|
||||
|
||||
- `collection` — Target collection slug (required)
|
||||
- `allowMultiple` — Allow multiple references (default: false)
|
||||
|
||||
**Single reference stored value:**
|
||||
|
||||
```json
|
||||
"01HXK5MZSN..."
|
||||
```
|
||||
|
||||
**Multiple references stored value:**
|
||||
|
||||
```json
|
||||
["01HXK5MZSN...", "01HXK6NATS..."]
|
||||
```
|
||||
|
||||
## Flexible Types
|
||||
|
||||
### `json`
|
||||
|
||||
Arbitrary JSON data. Use for complex nested structures, third-party integrations, or data without a fixed schema.
|
||||
|
||||
```ts
|
||||
{
|
||||
slug: "metadata",
|
||||
label: "Metadata",
|
||||
type: "json",
|
||||
}
|
||||
```
|
||||
|
||||
Stored as-is in SQLite JSON column.
|
||||
|
||||
<Aside type="caution">JSON fields have no validation. Use sparingly for truly dynamic data.</Aside>
|
||||
|
||||
## Field Properties
|
||||
|
||||
All fields support these common properties:
|
||||
|
||||
| Property | Type | Description |
|
||||
| -------------- | ----------- | ----------------------------------- |
|
||||
| `slug` | `string` | Unique identifier (required) |
|
||||
| `label` | `string` | Display name (required) |
|
||||
| `type` | `FieldType` | Field type (required) |
|
||||
| `required` | `boolean` | Require a value (default: false) |
|
||||
| `unique` | `boolean` | Enforce uniqueness (default: false) |
|
||||
| `defaultValue` | `unknown` | Default value for new entries |
|
||||
| `validation` | `object` | Type-specific validation rules |
|
||||
| `widget` | `string` | Custom widget override |
|
||||
| `options` | `object` | Widget configuration |
|
||||
| `sortOrder` | `number` | Display order in admin |
|
||||
|
||||
## Reserved Field Slugs
|
||||
|
||||
These slugs are reserved and cannot be used:
|
||||
|
||||
- `id`
|
||||
- `slug`
|
||||
- `status`
|
||||
- `author_id`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `published_at`
|
||||
- `deleted_at`
|
||||
- `version`
|
||||
|
||||
## TypeScript Types
|
||||
|
||||
Import field types for programmatic use:
|
||||
|
||||
```ts
|
||||
import type { FieldType, Field, CreateFieldInput } from "emdash";
|
||||
|
||||
const fieldTypes: FieldType[] = [
|
||||
"string",
|
||||
"text",
|
||||
"number",
|
||||
"integer",
|
||||
"boolean",
|
||||
"datetime",
|
||||
"select",
|
||||
"multiSelect",
|
||||
"portableText",
|
||||
"image",
|
||||
"file",
|
||||
"reference",
|
||||
"json",
|
||||
"slug",
|
||||
];
|
||||
```
|
||||
714
docs/src/content/docs/reference/hooks.mdx
Normal file
@@ -0,0 +1,714 @@
|
||||
---
|
||||
title: Hook Reference
|
||||
description: Plugin hooks for extending EmDash functionality.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
Hooks allow plugins to intercept and modify EmDash behavior at specific points in the content, media, email, comment, and page lifecycle.
|
||||
|
||||
## Hook Overview
|
||||
|
||||
| Hook | Trigger | Can Modify | Exclusive |
|
||||
| ----------------------- | ------------------------------------- | ----------------- | --------- |
|
||||
| `content:beforeSave` | Before content is saved | Content data | No |
|
||||
| `content:afterSave` | After content is saved | Nothing | No |
|
||||
| `content:beforeDelete` | Before content is deleted | Can cancel | No |
|
||||
| `content:afterDelete` | After content is deleted | Nothing | No |
|
||||
| `media:beforeUpload` | Before file is uploaded | File metadata | No |
|
||||
| `media:afterUpload` | After file is uploaded | Nothing | No |
|
||||
| `cron` | Scheduled task fires | Nothing | No |
|
||||
| `email:beforeSend` | Before email delivery | Message, can cancel | No |
|
||||
| `email:deliver` | Deliver email via transport | Nothing | Yes |
|
||||
| `email:afterSend` | After successful email delivery | Nothing | No |
|
||||
| `comment:beforeCreate` | Before comment is stored | Comment, can cancel | No |
|
||||
| `comment:moderate` | Decide comment approval status | Status | Yes |
|
||||
| `comment:afterCreate` | After comment is stored | Nothing | No |
|
||||
| `comment:afterModerate` | After admin changes comment status | Nothing | No |
|
||||
| `page:metadata` | Rendering public page head | Contribute tags | No |
|
||||
| `page:fragments` | Rendering public page body | Inject scripts | No |
|
||||
| `plugin:install` | When plugin is first installed | Nothing | No |
|
||||
| `plugin:activate` | When plugin is enabled | Nothing | No |
|
||||
| `plugin:deactivate` | When plugin is disabled | Nothing | No |
|
||||
| `plugin:uninstall` | When plugin is removed | Nothing | No |
|
||||
|
||||
## Content Hooks
|
||||
|
||||
### `content:beforeSave`
|
||||
|
||||
Runs before content is saved to the database. Use to validate, transform, or enrich content.
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "emdash";
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
version: "1.0.0",
|
||||
hooks: {
|
||||
"content:beforeSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
// Add timestamps
|
||||
if (isNew) {
|
||||
content.createdBy = "system";
|
||||
}
|
||||
content.modifiedAt = new Date().toISOString();
|
||||
|
||||
// Return modified content
|
||||
return content;
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface ContentHookEvent {
|
||||
content: Record<string, unknown>; // Content data
|
||||
collection: string; // Collection slug
|
||||
isNew: boolean; // True for creates, false for updates
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
- Return modified content object to apply changes
|
||||
- Return `void` to pass through unchanged
|
||||
|
||||
### `content:afterSave`
|
||||
|
||||
Runs after content is saved. Use for side effects like notifications, cache invalidation, or external syncing.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:afterSave": async (event, ctx) => {
|
||||
const { content, collection, isNew } = event;
|
||||
|
||||
if (collection === "posts" && content.status === "published") {
|
||||
// Notify external service
|
||||
await ctx.http?.fetch("https://api.example.com/notify", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ postId: content.id }),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
No return value expected.
|
||||
|
||||
### `content:beforeDelete`
|
||||
|
||||
Runs before content is deleted. Use to validate deletion or prevent it.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:beforeDelete": async (event, ctx) => {
|
||||
const { id, collection } = event;
|
||||
|
||||
// Prevent deletion of protected content
|
||||
const item = await ctx.content?.get(collection, id);
|
||||
if (item?.data.protected) {
|
||||
return false; // Cancel deletion
|
||||
}
|
||||
|
||||
// Allow deletion
|
||||
return true;
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface ContentDeleteEvent {
|
||||
id: string; // Entry ID
|
||||
collection: string; // Collection slug
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
- Return `false` to cancel deletion
|
||||
- Return `true` or `void` to allow
|
||||
|
||||
### `content:afterDelete`
|
||||
|
||||
Runs after content is deleted. Use for cleanup tasks.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:afterDelete": async (event, ctx) => {
|
||||
const { id, collection } = event;
|
||||
|
||||
// Clean up related data
|
||||
await ctx.storage.relatedItems.delete(`${collection}:${id}`);
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Media Hooks
|
||||
|
||||
### `media:beforeUpload`
|
||||
|
||||
Runs before a file is uploaded. Use to validate, rename, or reject files.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"media:beforeUpload": async (event, ctx) => {
|
||||
const { file } = event;
|
||||
|
||||
// Reject files over 10MB
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
throw new Error("File too large");
|
||||
}
|
||||
|
||||
// Rename file
|
||||
return {
|
||||
name: `${Date.now()}-${file.name}`,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
};
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface MediaUploadEvent {
|
||||
file: {
|
||||
name: string; // Original filename
|
||||
type: string; // MIME type
|
||||
size: number; // Size in bytes
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
- Return modified file metadata to apply changes
|
||||
- Return `void` to pass through unchanged
|
||||
- Throw to reject the upload
|
||||
|
||||
### `media:afterUpload`
|
||||
|
||||
Runs after a file is uploaded. Use for processing, thumbnails, or metadata extraction.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"media:afterUpload": async (event, ctx) => {
|
||||
const { media } = event;
|
||||
|
||||
if (media.mimeType.startsWith("image/")) {
|
||||
// Store image metadata
|
||||
await ctx.kv.set(`media:${media.id}:analyzed`, {
|
||||
processedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface MediaAfterUploadEvent {
|
||||
media: {
|
||||
id: string;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
size: number | null;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
### `plugin:install`
|
||||
|
||||
Runs when a plugin is first installed. Use for initial setup, creating storage collections, or seeding data.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"plugin:install": async (event, ctx) => {
|
||||
// Initialize default settings
|
||||
await ctx.kv.set("settings:enabled", true);
|
||||
await ctx.kv.set("settings:threshold", 100);
|
||||
|
||||
ctx.log.info("Plugin installed successfully");
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `plugin:activate`
|
||||
|
||||
Runs when a plugin is enabled (after install or re-enable).
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"plugin:activate": async (event, ctx) => {
|
||||
ctx.log.info("Plugin activated");
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `plugin:deactivate`
|
||||
|
||||
Runs when a plugin is disabled.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"plugin:deactivate": async (event, ctx) => {
|
||||
ctx.log.info("Plugin deactivated");
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `plugin:uninstall`
|
||||
|
||||
Runs when a plugin is removed. Use for cleanup.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"plugin:uninstall": async (event, ctx) => {
|
||||
const { deleteData } = event;
|
||||
|
||||
if (deleteData) {
|
||||
// Clean up all plugin data
|
||||
const items = await ctx.kv.list("settings:");
|
||||
for (const { key } of items) {
|
||||
await ctx.kv.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.log.info("Plugin uninstalled");
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface UninstallEvent {
|
||||
deleteData: boolean; // User chose to delete data
|
||||
}
|
||||
```
|
||||
|
||||
## Cron Hook
|
||||
|
||||
### `cron`
|
||||
|
||||
Fired when a scheduled task executes. Schedule tasks with `ctx.cron.schedule()`.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"cron": async (event, ctx) => {
|
||||
if (event.name === "daily-sync") {
|
||||
const data = await ctx.http?.fetch("https://api.example.com/data");
|
||||
ctx.log.info("Sync complete");
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface CronEvent {
|
||||
name: string;
|
||||
data?: Record<string, unknown>;
|
||||
scheduledAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Email Hooks
|
||||
|
||||
Email hooks form a pipeline: `email:beforeSend` → `email:deliver` → `email:afterSend`.
|
||||
|
||||
### `email:beforeSend`
|
||||
|
||||
**Capability:** `hooks.email-events:register`
|
||||
|
||||
Middleware hook that runs before delivery. Transform messages or cancel delivery.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"email:beforeSend": async (event, ctx) => {
|
||||
// Add footer to all emails
|
||||
return {
|
||||
...event.message,
|
||||
text: event.message.text + "\n\n—Sent from My Site",
|
||||
};
|
||||
|
||||
// Or return false to cancel delivery
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface EmailBeforeSendEvent {
|
||||
message: { to: string; subject: string; text: string; html?: string };
|
||||
source: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
- Return modified message to transform
|
||||
- Return `false` to cancel delivery
|
||||
- Return `void` to pass through unchanged
|
||||
|
||||
### `email:deliver`
|
||||
|
||||
**Capability:** `hooks.email-transport:register` | **Exclusive:** Yes
|
||||
|
||||
The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"email:deliver": {
|
||||
exclusive: true,
|
||||
handler: async (event, ctx) => {
|
||||
await sendViaSES(event.message);
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `email:afterSend`
|
||||
|
||||
**Capability:** `hooks.email-events:register`
|
||||
|
||||
Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"email:afterSend": async (event, ctx) => {
|
||||
await ctx.kv.set(`email:log:${Date.now()}`, {
|
||||
to: event.message.to,
|
||||
subject: event.message.subject,
|
||||
});
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Comment Hooks
|
||||
|
||||
Comment hooks form a pipeline: `comment:beforeCreate` → `comment:moderate` → `comment:afterCreate`. The `comment:afterModerate` hook fires separately when an admin changes a comment's status.
|
||||
|
||||
### `comment:beforeCreate`
|
||||
|
||||
**Capability:** `users:read`
|
||||
|
||||
Middleware hook before a comment is stored. Enrich, validate, or reject comments.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"comment:beforeCreate": async (event, ctx) => {
|
||||
// Reject comments with links
|
||||
if (event.comment.body.includes("http")) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface CommentBeforeCreateEvent {
|
||||
comment: {
|
||||
collection: string;
|
||||
contentId: string;
|
||||
parentId: string | null;
|
||||
authorName: string;
|
||||
authorEmail: string;
|
||||
authorUserId: string | null;
|
||||
body: string;
|
||||
ipHash: string | null;
|
||||
userAgent: string | null;
|
||||
};
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
- Return modified event to transform
|
||||
- Return `false` to reject
|
||||
- Return `void` to pass through
|
||||
|
||||
### `comment:moderate`
|
||||
|
||||
**Capability:** `users:read` | **Exclusive:** Yes
|
||||
|
||||
Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"comment:moderate": {
|
||||
exclusive: true,
|
||||
handler: async (event, ctx) => {
|
||||
const score = await checkSpam(event.comment);
|
||||
return {
|
||||
status: score > 0.8 ? "spam" : score > 0.5 ? "pending" : "approved",
|
||||
reason: `Spam score: ${score}`,
|
||||
};
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface CommentModerateEvent {
|
||||
comment: { /* same as beforeCreate */ };
|
||||
metadata: Record<string, unknown>;
|
||||
collectionSettings: {
|
||||
commentsEnabled: boolean;
|
||||
commentsModeration: "all" | "first_time" | "none";
|
||||
commentsClosedAfterDays: number;
|
||||
commentsAutoApproveUsers: boolean;
|
||||
};
|
||||
priorApprovedCount: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### Return Value
|
||||
|
||||
```ts
|
||||
{ status: "approved" | "pending" | "spam"; reason?: string }
|
||||
```
|
||||
|
||||
### `comment:afterCreate`
|
||||
|
||||
**Capability:** `users:read`
|
||||
|
||||
Fire-and-forget hook after a comment is stored. Use for notifications.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"comment:afterCreate": async (event, ctx) => {
|
||||
if (event.comment.status === "approved") {
|
||||
await ctx.email?.send({
|
||||
to: event.contentAuthor?.email,
|
||||
subject: `New comment on "${event.content.title}"`,
|
||||
text: `${event.comment.authorName} commented: ${event.comment.body}`,
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### `comment:afterModerate`
|
||||
|
||||
**Capability:** `users:read`
|
||||
|
||||
Fire-and-forget hook when an admin manually changes a comment's status.
|
||||
|
||||
#### Event
|
||||
|
||||
```ts
|
||||
interface CommentAfterModerateEvent {
|
||||
comment: { id: string; /* ... */ };
|
||||
previousStatus: string;
|
||||
newStatus: string;
|
||||
moderator: { id: string; name: string | null };
|
||||
}
|
||||
```
|
||||
|
||||
## Page Hooks
|
||||
|
||||
Page hooks run when rendering public pages. They allow plugins to inject metadata and scripts.
|
||||
|
||||
### `page:metadata`
|
||||
|
||||
**Capability:** None required
|
||||
|
||||
Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"page:metadata": async (event, ctx) => {
|
||||
return [
|
||||
{ kind: "meta", name: "generator", content: "EmDash" },
|
||||
{ kind: "property", property: "og:site_name", content: event.page.siteName },
|
||||
{ kind: "jsonld", graph: { "@type": "WebSite", name: event.page.siteName } },
|
||||
];
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Contribution Types
|
||||
|
||||
```ts
|
||||
type PageMetadataContribution =
|
||||
| { kind: "meta"; name: string; content: string; key?: string }
|
||||
| { kind: "property"; property: string; content: string; key?: string }
|
||||
| { kind: "link"; rel: string; href: string; hreflang?: string; key?: string }
|
||||
| { kind: "jsonld"; id?: string; graph: Record<string, unknown> };
|
||||
```
|
||||
|
||||
The `key` field deduplicates contributions — only the last contribution with a given key is used.
|
||||
|
||||
### `page:fragments`
|
||||
|
||||
**Capability:** `hooks.page-fragments:register`
|
||||
|
||||
Inject scripts or HTML into pages. Only available to native plugins.
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"page:fragments": async (event, ctx) => {
|
||||
return [
|
||||
{
|
||||
kind: "external-script",
|
||||
placement: "body:end",
|
||||
src: "https://analytics.example.com/script.js",
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
kind: "inline-script",
|
||||
placement: "head",
|
||||
code: `window.siteId = "abc123";`,
|
||||
},
|
||||
];
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### Contribution Types
|
||||
|
||||
```ts
|
||||
type PageFragmentContribution =
|
||||
| {
|
||||
kind: "external-script";
|
||||
placement: "head" | "body:start" | "body:end";
|
||||
src: string;
|
||||
async?: boolean;
|
||||
defer?: boolean;
|
||||
attributes?: Record<string, string>;
|
||||
key?: string;
|
||||
}
|
||||
| {
|
||||
kind: "inline-script";
|
||||
placement: "head" | "body:start" | "body:end";
|
||||
code: string;
|
||||
attributes?: Record<string, string>;
|
||||
key?: string;
|
||||
}
|
||||
| {
|
||||
kind: "html";
|
||||
placement: "head" | "body:start" | "body:end";
|
||||
html: string;
|
||||
key?: string;
|
||||
};
|
||||
```
|
||||
|
||||
## Hook Configuration
|
||||
|
||||
Hooks accept either a handler function or a configuration object:
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
// Simple handler
|
||||
"content:afterSave": async (event, ctx) => { ... },
|
||||
|
||||
// With configuration
|
||||
"content:beforeSave": {
|
||||
priority: 50, // Lower runs first (default: 100)
|
||||
timeout: 10000, // Max execution time in ms (default: 5000)
|
||||
dependencies: [], // Run after these plugins
|
||||
errorPolicy: "abort", // "continue" or "abort" (default)
|
||||
handler: async (event, ctx) => { ... },
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| -------------- | ---------- | --------- | ---------------------------------------------------- |
|
||||
| `priority` | `number` | `100` | Execution order (lower = earlier) |
|
||||
| `timeout` | `number` | `5000` | Max execution time in milliseconds |
|
||||
| `dependencies` | `string[]` | `[]` | Plugin IDs that must run first |
|
||||
| `errorPolicy` | `string` | `"abort"` | `"continue"` to ignore errors |
|
||||
| `exclusive` | `boolean` | `false` | Only one plugin can be the active provider (for provider-pattern hooks like `email:deliver`, `comment:moderate`) |
|
||||
|
||||
## Plugin Context
|
||||
|
||||
All hooks receive a context object with access to plugin APIs:
|
||||
|
||||
```ts
|
||||
interface PluginContext {
|
||||
plugin: { id: string; version: string };
|
||||
storage: PluginStorage;
|
||||
kv: KVAccess;
|
||||
content?: ContentAccess;
|
||||
media?: MediaAccess;
|
||||
http?: HttpAccess;
|
||||
log: LogAccess;
|
||||
site: { name: string; url: string; locale: string };
|
||||
url(path: string): string;
|
||||
users?: UserAccess;
|
||||
cron?: CronAccess;
|
||||
email?: EmailAccess;
|
||||
}
|
||||
```
|
||||
|
||||
See [Plugin Overview — Plugin Context](/plugins/overview/#plugin-context) for capability requirements and method details.
|
||||
|
||||
<Aside>
|
||||
Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin
|
||||
definition.
|
||||
</Aside>
|
||||
|
||||
## Error Handling
|
||||
|
||||
Errors in hooks are logged and handled based on `errorPolicy`:
|
||||
|
||||
- `"abort"` (default) — Stop execution, rollback transaction if applicable
|
||||
- `"continue"` — Log error and continue to next hook
|
||||
|
||||
```ts
|
||||
hooks: {
|
||||
"content:beforeSave": {
|
||||
errorPolicy: "continue", // Don't block save if this fails
|
||||
handler: async (event, ctx) => {
|
||||
try {
|
||||
await ctx.http?.fetch("https://api.example.com/validate");
|
||||
} catch (error) {
|
||||
ctx.log.warn("Validation service unavailable", error);
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Execution Order
|
||||
|
||||
Hooks run in this order:
|
||||
|
||||
1. Sorted by `priority` (ascending)
|
||||
2. Plugins with `dependencies` run after their dependencies
|
||||
3. Within same priority, order is deterministic but unspecified
|
||||
|
||||
```ts
|
||||
// This runs first (priority 10)
|
||||
{ priority: 10, handler: ... }
|
||||
|
||||
// This runs second (priority 50)
|
||||
{ priority: 50, handler: ... }
|
||||
|
||||
// This runs last (default priority 100)
|
||||
{ handler: ... }
|
||||
```
|
||||
742
docs/src/content/docs/reference/mcp-server.mdx
Normal file
@@ -0,0 +1,742 @@
|
||||
---
|
||||
title: MCP Server Reference
|
||||
description: Protocol details, tool specifications, and OAuth configuration for the MCP server.
|
||||
---
|
||||
|
||||
import { Aside } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash includes a built-in [Model Context Protocol](https://modelcontextprotocol.io) (MCP) server at `/_emdash/api/mcp` that exposes content management operations as tools for AI assistants.
|
||||
|
||||
<Aside>
|
||||
Looking to connect Claude, ChatGPT, or another AI tool to your site? See the [AI Tools guide](/guides/ai-tools) for setup instructions and usage tips.
|
||||
</Aside>
|
||||
|
||||
This page covers the protocol details: authentication, transport, tool specifications, OAuth discovery, and error handling.
|
||||
|
||||
## Authentication
|
||||
|
||||
The MCP server supports three authentication methods:
|
||||
|
||||
| Method | How it works |
|
||||
| --- | --- |
|
||||
| **OAuth 2.1 Authorization Code + PKCE** | Standard flow for MCP clients. User approves scopes in the browser. |
|
||||
| **Personal Access Token (PAT)** | Long-lived `ec_pat_*` tokens created in the admin panel. |
|
||||
| **Device Flow** | CLI-style flow where you approve a code in the browser. Used by `emdash login`. |
|
||||
|
||||
Session cookies (from the admin UI) also work but aren't practical for external MCP clients.
|
||||
|
||||
### Scopes
|
||||
|
||||
Tokens are scoped to limit what operations a client can perform. Scopes are requested during OAuth authorization and enforced on every tool call.
|
||||
|
||||
| Scope | Grants access to |
|
||||
| --- | --- |
|
||||
| `content:read` | List, get, compare, and search content. List taxonomies, taxonomy terms, and menus. |
|
||||
| `content:write` | Create, update, delete, publish, unpublish, schedule, unschedule, duplicate, and restore content. Implicitly grants `taxonomies:manage` and `menus:manage` for backwards compatibility with tokens issued before those scopes existed. |
|
||||
| `media:read` | List and get media items. |
|
||||
| `media:write` | Register (create), update, and delete media metadata. |
|
||||
| `schema:read` | List collections and get collection schemas. |
|
||||
| `schema:write` | Create and delete collections and fields. |
|
||||
| `taxonomies:manage` | Create, update, and delete taxonomy terms. |
|
||||
| `menus:manage` | Create, update, and delete navigation menus and their items. |
|
||||
| `settings:read` | Read site-wide settings. |
|
||||
| `settings:manage` | Update site-wide settings. |
|
||||
| `admin` | Full access to all operations. |
|
||||
|
||||
The `admin` scope grants access to everything. Session-based auth (no token) also has full access based on the user's role.
|
||||
|
||||
`content:write` implicitly grants `taxonomies:manage` and `menus:manage` so personal access tokens issued before those scopes were split out continue to work without re-issue. New tokens should request the granular scopes.
|
||||
|
||||
### Role Requirements
|
||||
|
||||
In addition to scopes, some tools require a minimum RBAC role. Both must be satisfied -- a token with the right scope still fails if the calling user's role is too low.
|
||||
|
||||
| Operation | Minimum role |
|
||||
| --- | --- |
|
||||
| Content read | Subscriber (10) for published items; Contributor (20) for drafts, scheduled, trash, and revisions |
|
||||
| Content create / edit own / delete own | Author (30) |
|
||||
| Content publish | Author (30) for own items; Editor (40) to act on others' items |
|
||||
| Schema read | Editor (40) |
|
||||
| Schema write | Admin (50) |
|
||||
| Taxonomies manage | Editor (40) |
|
||||
| Menus manage | Editor (40) |
|
||||
| Settings read | Editor (40) |
|
||||
| Settings manage | Admin (50) |
|
||||
| Media upload (`media_create`) | Author (30) |
|
||||
|
||||
See the [Authentication guide](/guides/authentication#user-roles) for role definitions.
|
||||
|
||||
## Transport
|
||||
|
||||
The server uses the Streamable HTTP transport in **stateless mode**. Each request is independent -- there are no sessions or long-lived connections.
|
||||
|
||||
- **`POST /_emdash/api/mcp`** -- Send JSON-RPC tool calls
|
||||
- **`GET /_emdash/api/mcp`** -- Returns 405 (no SSE in stateless mode)
|
||||
- **`DELETE /_emdash/api/mcp`** -- Returns 405 (no session to close)
|
||||
|
||||
Responses follow the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) format. Errors use standard JSON-RPC error codes, with MCP-specific codes for scope and permission failures.
|
||||
|
||||
## Tools
|
||||
|
||||
The server exposes 43 tools across eight domains: content, schema, media, search, taxonomies, menus, revisions, and settings. Each tool returns results as JSON text content, or an error message with `isError: true` on failure.
|
||||
|
||||
### Content Tools
|
||||
|
||||
#### `content_list`
|
||||
|
||||
List content items in a collection with optional filtering and pagination.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug (e.g. `posts`, `pages`) |
|
||||
| `status` | `string` | No | Filter: `draft`, `published`, or `scheduled` |
|
||||
| `limit` | `integer` | No | Max items to return (1-100, default 50) |
|
||||
| `cursor` | `string` | No | Pagination cursor from a previous response |
|
||||
| `orderBy` | `string` | No | Field to sort by (e.g. `created_at`, `updated_at`) |
|
||||
| `order` | `string` | No | Sort direction: `asc` or `desc` (default `desc`) |
|
||||
| `locale` | `string` | No | Filter by locale (e.g. `en`, `fr`). Only relevant with i18n. |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `content_get`
|
||||
|
||||
Get a single content item by ID or slug. Returns all field values, metadata, and a `_rev` token for optimistic concurrency.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID (ULID) or slug |
|
||||
| `locale` | `string` | No | Locale for slug lookup. IDs are globally unique. |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
<Aside>
|
||||
The `_rev` token in the response is used for conflict detection. Pass it back when updating to ensure no one else has modified the item since you read it.
|
||||
</Aside>
|
||||
|
||||
#### `content_create`
|
||||
|
||||
Create a new content item. The `data` object should contain field values matching the collection's schema -- use `schema_get_collection` to check what fields are available. Items are created as `draft` by default.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `data` | `object` | Yes | Field values as key-value pairs |
|
||||
| `slug` | `string` | No | URL slug (auto-generated from title if omitted) |
|
||||
| `status` | `string` | No | Initial status: `draft` or `published` (default `draft`) |
|
||||
| `locale` | `string` | No | Locale for this content (defaults to site default) |
|
||||
| `translationOf` | `string` | No | ID of the item this is a translation of |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_update`
|
||||
|
||||
Update an existing content item. Only include fields you want to change -- unspecified fields are left unchanged.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
| `data` | `object` | No | Field values to update |
|
||||
| `slug` | `string` | No | New URL slug |
|
||||
| `status` | `string` | No | New status: `draft` or `published` |
|
||||
| `_rev` | `string` | No | Revision token from `content_get` for conflict detection |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_delete`
|
||||
|
||||
Soft-delete a content item by moving it to the trash. Use `content_restore` to undo, or `content_permanent_delete` to remove it forever.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write` | **Destructive:** Yes
|
||||
|
||||
#### `content_restore`
|
||||
|
||||
Restore a soft-deleted content item from the trash.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_permanent_delete`
|
||||
|
||||
Permanently and irreversibly delete a trashed content item. The item must be in the trash first.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write` | **Destructive:** Yes
|
||||
|
||||
#### `content_publish`
|
||||
|
||||
Publish a content item, making it live on the site. Creates a published revision from the current draft. Further edits create a new draft without affecting the live version until re-published.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_unpublish`
|
||||
|
||||
Revert a published item to draft status. It will no longer be visible on the live site but its content is preserved.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_schedule`
|
||||
|
||||
Schedule a content item for future publication. It will be automatically published at the specified date/time.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
| `scheduledAt` | `string` | Yes | ISO 8601 datetime (e.g. `2026-06-01T09:00:00Z`) |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_unschedule`
|
||||
|
||||
Cancel a previously scheduled publication. The item keeps its current status; only the `scheduledAt` timestamp is cleared. Idempotent -- calling on an item that isn't scheduled is a no-op.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_compare`
|
||||
|
||||
Compare the published (live) version of a content item with its current draft. Returns both versions and a flag indicating whether there are changes.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `content_discard_draft`
|
||||
|
||||
Discard the current draft and revert to the last published version. Only works on items that have been published at least once.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:write` | **Destructive:** Yes
|
||||
|
||||
#### `content_list_trashed`
|
||||
|
||||
List soft-deleted content items in a collection's trash.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `limit` | `integer` | No | Max items (1-100, default 50) |
|
||||
| `cursor` | `string` | No | Pagination cursor |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `content_duplicate`
|
||||
|
||||
Create a copy of an existing content item. The duplicate is created as a draft with "(Copy)" appended to the title and an auto-generated slug.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug to duplicate |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
#### `content_translations`
|
||||
|
||||
Get all locale variants of a content item. Returns the translation group and a summary of each locale version. Only relevant when i18n is enabled.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
### Schema Tools
|
||||
|
||||
<Aside type="caution">
|
||||
Schema tools modify the database structure. Creating or deleting collections and fields changes the underlying tables. These operations require Admin role.
|
||||
</Aside>
|
||||
|
||||
#### `schema_list_collections`
|
||||
|
||||
List all content collections defined in the CMS. Returns slug, label, supported features, and timestamps.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Scope:** `schema:read` | **Minimum role:** Editor | **Read-only:** Yes
|
||||
|
||||
#### `schema_get_collection`
|
||||
|
||||
Get detailed info about a collection including all field definitions. Fields describe the data model: name, type, constraints, and validation rules. Use this to understand what `content_create` and `content_update` expect.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `slug` | `string` | Yes | Collection slug (e.g. `posts`) |
|
||||
|
||||
**Scope:** `schema:read` | **Minimum role:** Editor | **Read-only:** Yes
|
||||
|
||||
#### `schema_create_collection`
|
||||
|
||||
Create a new content collection. This creates a database table and schema definition. The slug must be lowercase alphanumeric with underscores, starting with a letter.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `slug` | `string` | Yes | Unique identifier (`/^[a-z][a-z0-9_]*$/`) |
|
||||
| `label` | `string` | Yes | Display name (plural, e.g. "Blog Posts") |
|
||||
| `labelSingular` | `string` | No | Singular display name |
|
||||
| `description` | `string` | No | Description of this collection |
|
||||
| `icon` | `string` | No | Icon name for the admin UI |
|
||||
| `supports` | `string[]` | No | Features: `drafts`, `revisions`, `preview`, `scheduling`, `search` (default: `['drafts', 'revisions']`) |
|
||||
|
||||
**Scope:** `schema:write` | **Minimum role:** Admin
|
||||
|
||||
#### `schema_delete_collection`
|
||||
|
||||
Delete a collection and its database table. This is irreversible and deletes all content in the collection.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `slug` | `string` | Yes | Collection slug to delete |
|
||||
| `force` | `boolean` | No | Force deletion even if the collection has content |
|
||||
|
||||
**Scope:** `schema:write` | **Minimum role:** Admin | **Destructive:** Yes
|
||||
|
||||
#### `schema_create_field`
|
||||
|
||||
Add a new field to a collection's schema. This adds a column to the database table.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `slug` | `string` | Yes | Field identifier (`/^[a-z][a-z0-9_]*$/`) |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `type` | `string` | Yes | Data type (see below) |
|
||||
| `required` | `boolean` | No | Whether the field is required |
|
||||
| `unique` | `boolean` | No | Whether values must be unique |
|
||||
| `defaultValue` | `any` | No | Default value for new items |
|
||||
| `validation` | `object` | No | Constraints: `min`, `max`, `minLength`, `maxLength`, `pattern`, `options` |
|
||||
| `options` | `object` | No | Widget config: `collection` (for references), `rows` (for textarea) |
|
||||
| `searchable` | `boolean` | No | Include in full-text search index |
|
||||
| `translatable` | `boolean` | No | Whether this field is translatable (default true) |
|
||||
|
||||
Field types: `string`, `text`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `portableText`, `image`, `file`, `reference`, `json`, `slug`.
|
||||
|
||||
For `select` and `multiSelect` types, provide allowed values in `validation.options`.
|
||||
|
||||
**Scope:** `schema:write` | **Minimum role:** Admin
|
||||
|
||||
#### `schema_delete_field`
|
||||
|
||||
Remove a field from a collection. This drops the column and deletes all data in that field. Irreversible.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `fieldSlug` | `string` | Yes | Field slug to remove |
|
||||
|
||||
**Scope:** `schema:write` | **Minimum role:** Admin | **Destructive:** Yes
|
||||
|
||||
### Media Tools
|
||||
|
||||
#### `media_list`
|
||||
|
||||
List uploaded media files with optional MIME type filtering and pagination.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `mimeType` | `string` | No | Filter by MIME type prefix (e.g. `image/`, `application/pdf`) |
|
||||
| `limit` | `integer` | No | Max items (1-100, default 50) |
|
||||
| `cursor` | `string` | No | Pagination cursor |
|
||||
|
||||
**Scope:** `media:read` | **Read-only:** Yes
|
||||
|
||||
#### `media_create`
|
||||
|
||||
Register a media file that has already been uploaded to storage. The caller is responsible for placing the file at `storageKey` (typically using a signed upload URL from the admin UI or a separate API). This tool persists the metadata record so the file is discoverable via `media_list` / `media_get` and can be referenced by content.
|
||||
|
||||
<Aside>
|
||||
The MCP transport is not appropriate for binary uploads. Use the signed-upload flow to put the bytes in storage, then call `media_create` to register the record.
|
||||
</Aside>
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `filename` | `string` | Yes | Original filename (e.g. `logo.png`) |
|
||||
| `mimeType` | `string` | Yes | MIME type (e.g. `image/png`) |
|
||||
| `storageKey` | `string` | Yes | Storage path/key the file was uploaded to |
|
||||
| `size` | `integer` | No | File size in bytes |
|
||||
| `width` | `integer` | No | Image width in pixels |
|
||||
| `height` | `integer` | No | Image height in pixels |
|
||||
| `contentHash` | `string` | No | Hash of the file contents (for dedupe) |
|
||||
| `blurhash` | `string` | No | Blurhash for image placeholders |
|
||||
| `dominantColor` | `string` | No | Hex color string for the image's dominant color |
|
||||
|
||||
**Scope:** `media:write` | **Minimum role:** Author
|
||||
|
||||
#### `media_get`
|
||||
|
||||
Get details of a single media file by ID. Returns metadata including filename, MIME type, size, dimensions, alt text, and URL.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | Yes | Media item ID |
|
||||
|
||||
**Scope:** `media:read` | **Read-only:** Yes
|
||||
|
||||
#### `media_update`
|
||||
|
||||
Update metadata of an uploaded media file. The file itself cannot be changed.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | Yes | Media item ID |
|
||||
| `alt` | `string` | No | Alt text for accessibility |
|
||||
| `caption` | `string` | No | Caption text |
|
||||
| `width` | `integer` | No | Image width in pixels |
|
||||
| `height` | `integer` | No | Image height in pixels |
|
||||
|
||||
**Scope:** `media:write`
|
||||
|
||||
#### `media_delete`
|
||||
|
||||
Permanently delete a media file. Removes the database record and the file from storage. Content referencing this media will have broken references.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `id` | `string` | Yes | Media item ID |
|
||||
|
||||
**Scope:** `media:write` | **Destructive:** Yes
|
||||
|
||||
### Search Tool
|
||||
|
||||
#### `search`
|
||||
|
||||
Full-text search across content collections. Collections must have `search` in their `supports` list and fields must be marked as `searchable`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `query` | `string` | Yes | Search query text |
|
||||
| `collections` | `string[]` | No | Limit search to specific collection slugs |
|
||||
| `locale` | `string` | No | Filter results by locale |
|
||||
| `limit` | `integer` | No | Max results (1-50, default 20) |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
### Taxonomy Tools
|
||||
|
||||
#### `taxonomy_list`
|
||||
|
||||
List all taxonomy definitions (e.g. categories, tags). Returns name, label, whether hierarchical, and associated collections.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `taxonomy_list_terms`
|
||||
|
||||
List terms in a taxonomy with pagination.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `taxonomy` | `string` | Yes | Taxonomy name (e.g. `categories`, `tags`) |
|
||||
| `limit` | `integer` | No | Max items (1-100, default 50) |
|
||||
| `cursor` | `string` | No | Pagination cursor |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `taxonomy_create_term`
|
||||
|
||||
Create a new term in a taxonomy. For hierarchical taxonomies, specify a `parentId` to create a child term. The parent's ancestor chain must not exceed 100 levels.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `taxonomy` | `string` | Yes | Taxonomy name |
|
||||
| `slug` | `string` | Yes | URL-safe identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `parentId` | `string` | No | Parent term ID (for hierarchical taxonomies) |
|
||||
| `description` | `string` | No | Description of the term |
|
||||
|
||||
**Scope:** `taxonomies:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `taxonomy_update_term`
|
||||
|
||||
Update an existing term in a taxonomy. Any field can be omitted to leave it unchanged. Renaming a slug must not collide with another term in the same taxonomy. Set `parentId` to `null` to detach from a parent. The new parent must exist, belong to the same taxonomy, and not introduce a cycle.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `taxonomy` | `string` | Yes | Taxonomy name |
|
||||
| `termSlug` | `string` | Yes | Current slug of the term to update |
|
||||
| `slug` | `string` | No | New slug (must be unique in the taxonomy) |
|
||||
| `label` | `string` | No | New display name |
|
||||
| `parentId` | `string \| null` | No | New parent term ID; `null` to detach |
|
||||
| `description` | `string` | No | New description |
|
||||
|
||||
**Scope:** `taxonomies:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `taxonomy_delete_term`
|
||||
|
||||
Permanently delete a term from a taxonomy. Any content tagged with the term loses the association. Cannot delete a term that has children -- delete children first.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `taxonomy` | `string` | Yes | Taxonomy name |
|
||||
| `termSlug` | `string` | Yes | Slug of the term to delete |
|
||||
|
||||
**Scope:** `taxonomies:manage` | **Minimum role:** Editor | **Destructive:** Yes
|
||||
|
||||
### Menu Tools
|
||||
|
||||
#### `menu_list`
|
||||
|
||||
List all navigation menus. Returns name, label, and timestamps.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `menu_get`
|
||||
|
||||
Get a menu by name including all its items in order. Items have a label, URL, type, and optional parent for nesting.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name (e.g. `main`, `footer`) |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `menu_create`
|
||||
|
||||
Create a new navigation menu. The `name` is the stable identifier used by site templates; `label` is the human-readable name shown in the admin. Add items afterwards with `menu_set_items`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Stable identifier (`/^[a-z][a-z0-9_]*$/`) |
|
||||
| `label` | `string` | Yes | Display name for the admin |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `menu_update`
|
||||
|
||||
Update a menu's label. The `name` (stable identifier) cannot be changed.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name to update |
|
||||
| `label` | `string` | Yes | New display label |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor
|
||||
|
||||
#### `menu_delete`
|
||||
|
||||
Delete a menu and all its items. Cannot be undone.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name to delete |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor | **Destructive:** Yes
|
||||
|
||||
#### `menu_set_items`
|
||||
|
||||
Replace the entire item list of a menu in one call. Atomic: existing items are deleted and the new list is inserted in the order provided. Use this rather than per-item add/remove operations so the resulting order and parent links are unambiguous.
|
||||
|
||||
Items are positioned by array index. Nesting is expressed via `parentIndex` -- an item with `parentIndex: 0` is nested under the item at index `0`. The parent must appear earlier in the list. Items without `parentIndex` are top-level.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `name` | `string` | Yes | Menu name to update |
|
||||
| `items` | `MenuItem[]` | Yes | Ordered list of menu items (see below) |
|
||||
|
||||
Each `MenuItem` has:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `label` | `string` | Yes | Item display text |
|
||||
| `type` | `string` | Yes | One of `custom`, `page`, `post`, `taxonomy`, `collection` |
|
||||
| `customUrl` | `string` | No | URL for `type: "custom"` items (ignored otherwise) |
|
||||
| `referenceCollection` | `string` | No | Target collection slug for content references |
|
||||
| `referenceId` | `string` | No | Target content / term ID for references |
|
||||
| `titleAttr` | `string` | No | HTML `title` attribute |
|
||||
| `target` | `string` | No | HTML `target` attribute (e.g. `_blank`) |
|
||||
| `cssClasses` | `string` | No | Space-separated CSS classes |
|
||||
| `parentIndex` | `integer` | No | Array index of the parent item. Omit for top-level items. |
|
||||
|
||||
**Scope:** `menus:manage` | **Minimum role:** Editor
|
||||
|
||||
### Revision Tools
|
||||
|
||||
#### `revision_list`
|
||||
|
||||
List revision history for a content item, newest first. Requires the collection to support `revisions`.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `collection` | `string` | Yes | Collection slug |
|
||||
| `id` | `string` | Yes | Content item ID or slug |
|
||||
| `limit` | `integer` | No | Max revisions (1-50, default 20) |
|
||||
|
||||
**Scope:** `content:read` | **Read-only:** Yes
|
||||
|
||||
#### `revision_restore`
|
||||
|
||||
Restore a content item to a previous revision. Replaces the current draft with the specified revision's data. Not automatically published -- use `content_publish` afterward if needed.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `revisionId` | `string` | Yes | Revision ID to restore |
|
||||
|
||||
**Scope:** `content:write`
|
||||
|
||||
### Settings Tools
|
||||
|
||||
Site-wide settings -- title, tagline, logo, favicon, canonical URL, default page size, date and time formatting, social handles, and SEO defaults.
|
||||
|
||||
#### `settings_get`
|
||||
|
||||
Get all site-wide settings. Media references (`logo`, `favicon`, `seo.defaultOgImage`) include resolved URLs alongside the underlying `mediaId`. Unset values are omitted from the response.
|
||||
|
||||
**No parameters.**
|
||||
|
||||
**Scope:** `settings:read` | **Minimum role:** Editor | **Read-only:** Yes
|
||||
|
||||
#### `settings_update`
|
||||
|
||||
Update one or more site-wide settings. Partial update: only the fields provided are changed; omitted fields are left as-is. Returns the full settings object after the update.
|
||||
|
||||
To set a media reference (`logo`, `favicon`, `seo.defaultOgImage`), pass an object with `mediaId` (and optional `alt`). The media item must already exist -- use `media_create` first.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `title` | `string` | No | Site title |
|
||||
| `tagline` | `string` | No | Short description shown alongside the title |
|
||||
| `logo` | `MediaRef` | No | Logo media reference (`{ mediaId, alt? }`) |
|
||||
| `favicon` | `MediaRef` | No | Favicon media reference |
|
||||
| `url` | `string` | No | Canonical site URL (http or https). Empty string clears it. |
|
||||
| `postsPerPage` | `integer` | No | Default page size for content listings (1-100) |
|
||||
| `dateFormat` | `string` | No | Date format token string |
|
||||
| `timezone` | `string` | No | IANA timezone identifier |
|
||||
| `social` | `object` | No | Social handles -- `twitter`, `github`, `facebook`, `instagram`, `linkedin`, `youtube` |
|
||||
| `seo` | `object` | No | SEO defaults (see below) |
|
||||
|
||||
The `seo` object accepts:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `titleSeparator` | `string` | Separator between page title and site title (e.g. `" \| "` for a vertical bar) |
|
||||
| `defaultOgImage` | `MediaRef` | Default Open Graph image when content has none |
|
||||
| `robotsTxt` | `string` | Custom `robots.txt` body. Omit to use the EmDash default. |
|
||||
| `googleVerification` | `string` | Google Search Console verification token |
|
||||
| `bingVerification` | `string` | Bing Webmaster Tools verification token |
|
||||
|
||||
**Scope:** `settings:manage` | **Minimum role:** Admin
|
||||
|
||||
## OAuth Discovery
|
||||
|
||||
MCP clients that support OAuth 2.1 can automatically discover how to authenticate. The server publishes two metadata documents:
|
||||
|
||||
### Protected Resource Metadata
|
||||
|
||||
```http
|
||||
GET /.well-known/oauth-protected-resource
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"resource": "https://example.com/_emdash/api/mcp",
|
||||
"authorization_servers": ["https://example.com/_emdash"],
|
||||
"scopes_supported": [
|
||||
"content:read", "content:write",
|
||||
"media:read", "media:write",
|
||||
"schema:read", "schema:write",
|
||||
"taxonomies:manage", "menus:manage",
|
||||
"settings:read", "settings:manage",
|
||||
"admin"
|
||||
],
|
||||
"bearer_methods_supported": ["header"]
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization Server Metadata
|
||||
|
||||
```http
|
||||
GET /.well-known/oauth-authorization-server/_emdash
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"issuer": "https://example.com/_emdash",
|
||||
"authorization_endpoint": "https://example.com/_emdash/oauth/authorize",
|
||||
"token_endpoint": "https://example.com/_emdash/api/oauth/token",
|
||||
"scopes_supported": ["content:read", "content:write", "..."],
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": [
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code"
|
||||
],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
"token_endpoint_auth_methods_supported": ["none"],
|
||||
"device_authorization_endpoint": "https://example.com/_emdash/api/oauth/device/code"
|
||||
}
|
||||
```
|
||||
|
||||
When an unauthenticated request hits the MCP endpoint, the server returns:
|
||||
|
||||
```http
|
||||
HTTP/1.1 401 Unauthorized
|
||||
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
|
||||
```
|
||||
|
||||
This triggers the standard MCP client discovery flow.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Tool errors are returned as text content with `isError: true`:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": [{ "type": "text", "text": "Collection 'nonexistent' not found" }],
|
||||
"isError": true
|
||||
}
|
||||
```
|
||||
|
||||
Scope and permission errors throw MCP protocol errors:
|
||||
|
||||
```json
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"error": {
|
||||
"code": -32600,
|
||||
"message": "Insufficient scope: requires content:write"
|
||||
},
|
||||
"id": 1
|
||||
}
|
||||
```
|
||||
|
||||
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code `-32603` (Internal error) without leaking implementation details.
|
||||
1022
docs/src/content/docs/reference/rest-api.mdx
Normal file
749
docs/src/content/docs/themes/creating-themes.mdx
vendored
Normal file
@@ -0,0 +1,749 @@
|
||||
---
|
||||
title: Creating Themes
|
||||
description: Build and distribute your own EmDash themes.
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
An EmDash theme is a complete Astro site -- pages, layouts, components, styles -- that also includes a seed file to bootstrap the content model. Build one to share your design with others, or to standardize site creation for your agency.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **A theme is a working Astro project.** There's no theme API or abstraction layer. You build a site and ship it as a template. The seed file just tells EmDash what collections, fields, menus, redirects, and taxonomies to create on first run.
|
||||
- **EmDash gives you more control over the content model than WordPress.** Themes take advantage of this -- the seed file declares exactly what fields each collection needs. Build on the standard **posts** and **pages** collections and add fields and taxonomies as your design requires, rather than inventing entirely new content types.
|
||||
- **Theme content pages must be server-rendered.** In a theme, content changes at runtime through the admin UI, so pages that display EmDash content must not be prerendered. Do not use `getStaticPaths()` in theme content routes. (Static site builds using EmDash as a build-time data source _can_ use `getStaticPaths`, but themes are always SSR.)
|
||||
- **No hard-coded content.** Site title, tagline, navigation, and other dynamic content come from the CMS via API calls -- not from template strings.
|
||||
|
||||
## Project Structure
|
||||
|
||||
Create a theme with this structure:
|
||||
|
||||
```
|
||||
my-emdash-theme/
|
||||
├── package.json # Theme metadata
|
||||
├── astro.config.mjs # Astro + EmDash configuration
|
||||
├── src/
|
||||
│ ├── live.config.ts # Live Collections setup
|
||||
│ ├── pages/
|
||||
│ │ ├── index.astro # Homepage
|
||||
│ │ ├── [...slug].astro # Pages (catch-all)
|
||||
│ │ ├── posts/
|
||||
│ │ │ ├── index.astro # Post archive
|
||||
│ │ │ └── [slug].astro # Single post
|
||||
│ │ ├── categories/
|
||||
│ │ │ └── [slug].astro # Category archive
|
||||
│ │ ├── tags/
|
||||
│ │ │ └── [slug].astro # Tag archive
|
||||
│ │ ├── search.astro # Search page
|
||||
│ │ └── 404.astro # Not found
|
||||
│ ├── layouts/
|
||||
│ │ └── Base.astro # Base layout
|
||||
│ └── components/ # Your components
|
||||
├── .emdash/
|
||||
│ ├── seed.json # Schema and sample content
|
||||
│ └── uploads/ # Optional local media files
|
||||
└── public/ # Static assets
|
||||
```
|
||||
|
||||
Pages live at the root as a catch-all route (`[...slug].astro`), so a page with slug `about` renders at `/about`. Posts, categories, and tags get their own directories. The `.emdash/` directory contains the seed file and any local media files used in sample content.
|
||||
|
||||
## Configuring package.json
|
||||
|
||||
Add the `emdash` field to your `package.json`:
|
||||
|
||||
```json title="package.json"
|
||||
{
|
||||
"name": "@your-org/emdash-theme-blog",
|
||||
"version": "1.0.0",
|
||||
"description": "A minimal blog theme for EmDash",
|
||||
"keywords": ["astro-template", "emdash", "blog"],
|
||||
"emdash": {
|
||||
"label": "Minimal Blog",
|
||||
"description": "A clean, minimal blog with posts, pages, and categories",
|
||||
"seed": ".emdash/seed.json",
|
||||
"preview": "https://your-theme-demo.pages.dev"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| ---------------------- | ----------------------------------- |
|
||||
| `emdash.label` | Display name shown in theme pickers |
|
||||
| `emdash.description` | Brief description of the theme |
|
||||
| `emdash.seed` | Path to the seed file |
|
||||
| `emdash.preview` | URL to a live demo (optional) |
|
||||
|
||||
## The Default Content Model
|
||||
|
||||
Most themes need two collection types: **posts** and **pages**. Posts are timestamped entries with excerpts and featured images that appear in feeds and archives. Pages are standalone content at top-level URLs.
|
||||
|
||||
This is the recommended starting point. Add more collections, taxonomies, or fields as your theme needs them, but start here.
|
||||
|
||||
### Seed File
|
||||
|
||||
The seed file tells EmDash what to create on first run. Create `.emdash/seed.json`:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "Minimal Blog",
|
||||
"description": "A clean blog with posts and pages",
|
||||
"author": "Your Name"
|
||||
},
|
||||
"settings": {
|
||||
"title": "My Blog",
|
||||
"tagline": "Thoughts and ideas",
|
||||
"postsPerPage": 10
|
||||
},
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"supports": ["drafts", "revisions"],
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText" },
|
||||
{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
|
||||
{ "slug": "featured_image", "label": "Featured Image", "type": "image" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"supports": ["drafts", "revisions"],
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "news", "label": "News" },
|
||||
{ "slug": "tutorials", "label": "Tutorials" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "Blog", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"redirects": [
|
||||
{ "source": "/category/news", "destination": "/categories/news" },
|
||||
{ "source": "/old-about", "destination": "/about" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Posts get `excerpt` and `featured_image` because they appear in lists and feeds. Pages don't need them -- they're standalone content. Add fields to either collection as your theme requires.
|
||||
|
||||
See [Seed File Format](/themes/seed-files/) for the complete specification, including sections, widget areas, and media references.
|
||||
|
||||
## Building Pages
|
||||
|
||||
All pages that display EmDash content are server-rendered. Use `Astro.params` to get the slug from the URL and query content at request time.
|
||||
|
||||
<Aside type="caution">
|
||||
In themes, never use `getStaticPaths()` or `export const prerender = true` for pages that display
|
||||
EmDash content. Themes serve content at runtime through the admin UI, so these pages must be
|
||||
server-rendered.
|
||||
</Aside>
|
||||
|
||||
### Homepage
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import { getEmDashCollection, getSiteSettings } from "emdash";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { status: "published" },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
limit: settings.postsPerPage ?? 10,
|
||||
});
|
||||
---
|
||||
|
||||
<Base title="Home">
|
||||
<h1>Latest Posts</h1>
|
||||
{posts.map((post) => (
|
||||
<article>
|
||||
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
</article>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Single Post
|
||||
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashEntry, getEntryTerms } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: post } = await getEmDashEntry("posts", slug!);
|
||||
|
||||
if (!post) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const categories = await getEntryTerms("posts", post.id, "categories");
|
||||
---
|
||||
|
||||
<Base title={post.data.title}>
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
<div class="post-meta">
|
||||
{categories.map((cat) => (
|
||||
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Pages
|
||||
|
||||
Pages use a catch-all route at the root so their slugs map directly to top-level URLs -- a page with slug `about` renders at `/about`:
|
||||
|
||||
```astro title="src/pages/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: page } = await getEmDashEntry("pages", slug!);
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={page.data.title}>
|
||||
<article>
|
||||
<h1>{page.data.title}</h1>
|
||||
<PortableText value={page.data.content} />
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
|
||||
Because this is a catch-all route, it only matches URLs that don't have a more specific route. `/posts/hello-world` still hits `posts/[slug].astro`, not this file.
|
||||
|
||||
### Category Archive
|
||||
|
||||
```astro title="src/pages/categories/[slug].astro"
|
||||
---
|
||||
import { getTerm, getEntriesByTerm } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const category = await getTerm("categories", slug!);
|
||||
const posts = await getEntriesByTerm("posts", "categories", slug!);
|
||||
|
||||
if (!category) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
---
|
||||
|
||||
<Base title={category.label}>
|
||||
<h1>{category.label}</h1>
|
||||
{posts.map((post) => (
|
||||
<article>
|
||||
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
|
||||
</article>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
|
||||
## Using Images
|
||||
|
||||
Image fields are objects with `src` and `alt` properties, not strings. Use the `Image` component from `emdash/ui` for optimized image rendering:
|
||||
|
||||
```astro title="src/components/PostCard.astro"
|
||||
---
|
||||
import { Image } from "emdash/ui";
|
||||
|
||||
const { post } = Astro.props;
|
||||
---
|
||||
|
||||
<article>
|
||||
{post.data.featured_image?.src && (
|
||||
<Image
|
||||
image={post.data.featured_image}
|
||||
alt={post.data.featured_image.alt || post.data.title}
|
||||
width={800}
|
||||
height={450}
|
||||
/>
|
||||
)}
|
||||
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
</article>
|
||||
```
|
||||
|
||||
<Aside type="caution">
|
||||
A common mistake is treating image fields as strings. `post.data.featured_image` is an object
|
||||
with `src` and `alt` -- writing `<img src={post.data.featured_image} />` renders `[object Object]`.
|
||||
</Aside>
|
||||
|
||||
## Using Menus
|
||||
|
||||
Query admin-defined menus in your layouts. Never hard-code navigation links:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getMenu, getSiteSettings } from "emdash";
|
||||
|
||||
const settings = await getSiteSettings();
|
||||
const primaryMenu = await getMenu("primary");
|
||||
---
|
||||
|
||||
<html>
|
||||
<head>
|
||||
<title>{Astro.props.title} | {settings.title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{settings.logo ? (
|
||||
<img src={settings.logo.url} alt={settings.title} />
|
||||
) : (
|
||||
<span>{settings.title}</span>
|
||||
)}
|
||||
<nav>
|
||||
{primaryMenu?.items.map((item) => (
|
||||
<a href={item.url}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Page Templates
|
||||
|
||||
Themes often need multiple page layouts -- a default layout, a full-width layout, a landing page layout. In EmDash, add a `template` select field to the pages collection and map it to layout components in your catch-all route.
|
||||
|
||||
Add the field to your pages collection in the seed file:
|
||||
|
||||
```json
|
||||
{
|
||||
"slug": "template",
|
||||
"label": "Page Template",
|
||||
"type": "string",
|
||||
"widget": "select",
|
||||
"options": {
|
||||
"choices": [
|
||||
{ "value": "default", "label": "Default" },
|
||||
{ "value": "full-width", "label": "Full Width" },
|
||||
{ "value": "landing", "label": "Landing Page" }
|
||||
]
|
||||
},
|
||||
"defaultValue": "default"
|
||||
}
|
||||
```
|
||||
|
||||
Then map the value to layout components in the catch-all route:
|
||||
|
||||
```astro title="src/pages/[...slug].astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import PageDefault from "../layouts/PageDefault.astro";
|
||||
import PageFullWidth from "../layouts/PageFullWidth.astro";
|
||||
import PageLanding from "../layouts/PageLanding.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const { entry: page } = await getEmDashEntry("pages", slug!);
|
||||
|
||||
if (!page) {
|
||||
return Astro.redirect("/404");
|
||||
}
|
||||
|
||||
const layouts = {
|
||||
"default": PageDefault,
|
||||
"full-width": PageFullWidth,
|
||||
"landing": PageLanding,
|
||||
};
|
||||
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
|
||||
---
|
||||
|
||||
<Layout page={page} />
|
||||
```
|
||||
|
||||
Editors choose the template from a dropdown in the admin UI when editing a page.
|
||||
|
||||
## Adding Sections
|
||||
|
||||
Sections are reusable content blocks that editors can insert into any Portable Text field using the `/section` slash command. If your theme has common content patterns (hero banners, CTAs, feature grids), define them as sections in the seed file:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"slug": "hero-centered",
|
||||
"title": "Centered Hero",
|
||||
"description": "Full-width hero with centered heading and CTA",
|
||||
"keywords": ["hero", "banner", "header", "landing"],
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h1",
|
||||
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"children": [
|
||||
{ "_type": "span", "text": "Your compelling tagline goes here." }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "newsletter-cta",
|
||||
"title": "Newsletter Signup",
|
||||
"keywords": ["newsletter", "subscribe", "email"],
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Get the latest updates delivered to your inbox."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Sections created from the seed file are marked with `source: "theme"`. Editors can also create their own sections (marked `source: "user"`), but theme-provided sections cannot be deleted from the admin UI.
|
||||
|
||||
## Adding Sample Content
|
||||
|
||||
Include sample content in the seed file to demonstrate your theme's design:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"content": {
|
||||
"posts": [
|
||||
{
|
||||
"id": "hello-world",
|
||||
"slug": "hello-world",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Hello World",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
|
||||
}
|
||||
],
|
||||
"excerpt": "Your first post on EmDash."
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["news"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Sample content is optional during setup. Users can uncheck "Include sample content" in the Setup
|
||||
Wizard if they want a clean start.
|
||||
</Aside>
|
||||
|
||||
## Including Media
|
||||
|
||||
Reference images in sample content using the `$media` syntax.
|
||||
|
||||
For remote images:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-xxx",
|
||||
"alt": "A descriptive alt text",
|
||||
"filename": "hero.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For local images, place files in `.emdash/uploads/` and reference them:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"file": "hero.jpg",
|
||||
"alt": "A descriptive alt text"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
During seeding, media files are downloaded (or read locally) and uploaded to storage.
|
||||
|
||||
## Search
|
||||
|
||||
If your theme includes a search page, use the `LiveSearch` component for instant results:
|
||||
|
||||
```astro title="src/pages/search.astro"
|
||||
---
|
||||
import LiveSearch from "emdash/ui/search";
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Search">
|
||||
<h1>Search</h1>
|
||||
<LiveSearch
|
||||
placeholder="Search posts and pages..."
|
||||
collections={["posts", "pages"]}
|
||||
/>
|
||||
</Base>
|
||||
```
|
||||
|
||||
`LiveSearch` provides debounced instant search with prefix matching, Porter stemming, and highlighted result snippets. Search must be enabled per-collection in the admin UI (Content Types > Edit > Features > Search).
|
||||
|
||||
## Testing Your Theme
|
||||
|
||||
1. Create a test project from your theme:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template ./path/to/my-theme
|
||||
```
|
||||
|
||||
2. Install dependencies and start the dev server:
|
||||
|
||||
```bash
|
||||
cd test-site
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Complete the Setup Wizard at `http://localhost:4321/_emdash/admin`
|
||||
|
||||
4. Verify collections, menus, redirects, and content were created correctly
|
||||
|
||||
5. Test all page templates render properly
|
||||
|
||||
6. Create new content through the admin to verify all fields work
|
||||
|
||||
## Publishing Your Theme
|
||||
|
||||
Publish to npm for distribution:
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
Users can then install your theme:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template @your-org/emdash-theme-blog
|
||||
```
|
||||
|
||||
For GitHub-hosted themes:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template github:your-org/emdash-theme-blog
|
||||
```
|
||||
|
||||
## Custom Portable Text Blocks
|
||||
|
||||
Themes can define custom Portable Text block types for specialized content. This is useful for marketing pages, landing pages, or any content that needs structured components beyond standard rich text.
|
||||
|
||||
### Defining Custom Blocks in Seed Content
|
||||
|
||||
Use a namespaced `_type` in your seed file's Portable Text content:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "home",
|
||||
"slug": "home",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Home",
|
||||
"content": [
|
||||
{
|
||||
"_type": "marketing.hero",
|
||||
"headline": "Build something amazing",
|
||||
"subheadline": "The all-in-one platform for modern teams.",
|
||||
"primaryCta": { "label": "Get Started", "url": "/signup" }
|
||||
},
|
||||
{
|
||||
"_type": "marketing.features",
|
||||
"_key": "features",
|
||||
"headline": "Everything you need",
|
||||
"features": [
|
||||
{
|
||||
"icon": "zap",
|
||||
"title": "Lightning fast",
|
||||
"description": "Built for speed."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Creating Block Components
|
||||
|
||||
Create Astro components for each custom block type:
|
||||
|
||||
```astro title="src/components/blocks/Hero.astro"
|
||||
---
|
||||
interface Props {
|
||||
value: {
|
||||
headline: string;
|
||||
subheadline?: string;
|
||||
primaryCta?: { label: string; url: string };
|
||||
};
|
||||
}
|
||||
|
||||
const { value } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="hero">
|
||||
<h1>{value.headline}</h1>
|
||||
{value.subheadline && <p>{value.subheadline}</p>}
|
||||
{value.primaryCta && (
|
||||
<a href={value.primaryCta.url} class="btn">
|
||||
{value.primaryCta.label}
|
||||
</a>
|
||||
)}
|
||||
</section>
|
||||
```
|
||||
|
||||
### Rendering Custom Blocks
|
||||
|
||||
Pass your custom block components to the `PortableText` component:
|
||||
|
||||
```astro title="src/components/MarketingBlocks.astro"
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Hero from "./blocks/Hero.astro";
|
||||
import Features from "./blocks/Features.astro";
|
||||
|
||||
interface Props {
|
||||
value: unknown[];
|
||||
}
|
||||
|
||||
const { value } = Astro.props;
|
||||
|
||||
const marketingTypes = {
|
||||
"marketing.hero": Hero,
|
||||
"marketing.features": Features,
|
||||
};
|
||||
---
|
||||
|
||||
<PortableText value={value} components={{ types: marketingTypes }} />
|
||||
```
|
||||
|
||||
Then use it in your pages:
|
||||
|
||||
```astro title="src/pages/index.astro"
|
||||
---
|
||||
import { getEmDashEntry } from "emdash";
|
||||
import MarketingBlocks from "../components/MarketingBlocks.astro";
|
||||
|
||||
const { entry: page } = await getEmDashEntry("pages", "home");
|
||||
---
|
||||
|
||||
<MarketingBlocks value={page.data.content} />
|
||||
```
|
||||
|
||||
<Aside>
|
||||
Custom block types don't have admin UI editors by default. Users can edit the seeded content
|
||||
through the standard Portable Text editor or modify the JSON directly. For a full admin editing
|
||||
experience, consider creating a plugin with custom editor components.
|
||||
</Aside>
|
||||
|
||||
### Anchor IDs for Navigation
|
||||
|
||||
Add `_key` to blocks that should be linkable:
|
||||
|
||||
```json
|
||||
{
|
||||
"_type": "marketing.features",
|
||||
"_key": "features",
|
||||
"headline": "Features"
|
||||
}
|
||||
```
|
||||
|
||||
Then use it as an anchor in your component:
|
||||
|
||||
```astro
|
||||
<section id={value._key}>
|
||||
<!-- content -->
|
||||
</section>
|
||||
```
|
||||
|
||||
This enables navigation links like `/#features`.
|
||||
|
||||
## Theme Checklist
|
||||
|
||||
Before publishing, verify your theme includes:
|
||||
|
||||
- [ ] `package.json` with `emdash` field (label, description, seed path)
|
||||
- [ ] `.emdash/seed.json` with valid schema
|
||||
- [ ] All collections referenced in pages exist in the seed
|
||||
- [ ] Menus used in layouts are defined in the seed
|
||||
- [ ] Sample content demonstrates the theme's design
|
||||
- [ ] `astro.config.mjs` with database and storage configuration
|
||||
- [ ] `src/live.config.ts` with EmDash loader
|
||||
- [ ] No `getStaticPaths()` on content pages
|
||||
- [ ] No hard-coded site title, tagline, or navigation
|
||||
- [ ] Image fields accessed as objects (`image.src`), not strings
|
||||
- [ ] README with setup instructions
|
||||
- [ ] Custom block components for any non-standard Portable Text types
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Seed File Format](/themes/seed-files/)** -- Complete reference for seed files
|
||||
- **[Themes Overview](/themes/overview/)** -- How themes work in EmDash
|
||||
- **[Porting WordPress Themes](/themes/porting-wp-themes/)** -- Convert existing WordPress themes
|
||||
160
docs/src/content/docs/themes/overview.mdx
vendored
Normal file
@@ -0,0 +1,160 @@
|
||||
---
|
||||
title: Themes Overview
|
||||
description: Understand how EmDash themes work and how they bootstrap new sites.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
An EmDash theme is a complete Astro site -- pages, layouts, components, styles -- distributed via `create-astro`. It also includes a **seed file** that bootstraps the database with collections, fields, menus, redirects, and sample content on first run.
|
||||
|
||||
## What a Theme Provides
|
||||
|
||||
A theme is a working Astro project with:
|
||||
|
||||
- **Pages** — Astro routes for rendering content (homepage, blog posts, archives, etc.)
|
||||
- **Layouts** — Shared HTML structure
|
||||
- **Components** — Reusable UI elements (navigation, cards, footers)
|
||||
- **Styles** — CSS or Tailwind configuration
|
||||
- **A seed file** — JSON that tells the CMS what content types and fields to create
|
||||
|
||||
<Aside>
|
||||
EmDash gives you far more control over the content model than WordPress does. Themes take
|
||||
advantage of this by declaring exactly which collections and fields they need via the seed file.
|
||||
Most themes should build on the standard **posts** and **pages** collections, adding fields and
|
||||
taxonomies as needed rather than inventing entirely new content types.
|
||||
</Aside>
|
||||
|
||||
## Theme Structure
|
||||
|
||||
```
|
||||
my-theme/
|
||||
├── package.json # Theme metadata + EmDash config
|
||||
├── astro.config.mjs # Astro integration setup
|
||||
├── src/
|
||||
│ ├── live.config.ts # Live Collections configuration
|
||||
│ ├── pages/ # Astro routes
|
||||
│ ├── layouts/ # Layout components
|
||||
│ └── components/ # UI components
|
||||
└── .emdash/
|
||||
├── seed.json # Schema + sample content
|
||||
└── uploads/ # Optional local media files
|
||||
```
|
||||
|
||||
## How Themes Bootstrap Sites
|
||||
|
||||
When you create a site from a theme, this happens:
|
||||
|
||||
1. `create-astro` scaffolds the project from the template
|
||||
2. You run `npm install` and `npm run dev`
|
||||
3. On first admin visit, the **Setup Wizard** runs automatically
|
||||
4. The wizard applies the seed file, creating collections, menus, redirects, and content
|
||||
5. The site is ready to use
|
||||
|
||||
<CardGrid>
|
||||
<Card title="For Users" icon="laptop">
|
||||
Pick a theme, run the wizard, start editing. No database knowledge required.
|
||||
</Card>
|
||||
<Card title="For Developers" icon="seti:config">
|
||||
Themes are standard Astro projects. Customize freely after scaffolding.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## Installing a Theme
|
||||
|
||||
Use `create-astro` with a template:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template @emdash-cms/template-blog
|
||||
```
|
||||
|
||||
Community themes work via GitHub:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template github:user/emdash-portfolio
|
||||
```
|
||||
|
||||
After installation:
|
||||
|
||||
```bash
|
||||
cd my-site
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:4321/_emdash/admin` to complete the Setup Wizard.
|
||||
|
||||
## The Setup Wizard
|
||||
|
||||
The Setup Wizard runs automatically on first admin visit. It:
|
||||
|
||||
1. Prompts for site title, tagline, and admin credentials
|
||||
2. Offers an option to include sample content
|
||||
3. Applies the seed file to the database
|
||||
4. Redirects to the admin dashboard
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ │
|
||||
│ ◆ EmDash │
|
||||
│ │
|
||||
│ Welcome to your new site │
|
||||
│ │
|
||||
│ Site Title: [My Awesome Blog ] │
|
||||
│ Tagline: [Thoughts and ideas ] │
|
||||
│ │
|
||||
│ Admin Email: [admin@example.com ] │
|
||||
│ Admin Password: [•••••••••••• ] │
|
||||
│ │
|
||||
│ ☑ Include sample content │
|
||||
│ │
|
||||
│ [Create Site →] │
|
||||
│ │
|
||||
│ Template: Blog Starter │
|
||||
│ Creates: 2 collections, 3 pages, 1 post │
|
||||
└────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
<Aside type="tip">
|
||||
Check "Include sample content" when exploring a theme for the first time. The sample content
|
||||
demonstrates how the theme expects content to be structured.
|
||||
</Aside>
|
||||
|
||||
## Official Themes
|
||||
|
||||
EmDash provides official starter themes, each available in local (SQLite + filesystem) and Cloudflare (D1 + R2) variants:
|
||||
|
||||
| Theme | Description | Use Case |
|
||||
| ----- | ----------- | -------- |
|
||||
| `@emdash-cms/template-blog` | Minimal blog with posts, pages, categories, and dark mode | Personal blogs, simple sites |
|
||||
| `@emdash-cms/template-portfolio` | Editorial-style portfolio with projects, serif typography (Playfair Display), and image-focused layouts | Freelancers, agencies, creatives |
|
||||
| `@emdash-cms/template-marketing` | Bold marketing site with custom Portable Text blocks (hero, features, testimonials, pricing, FAQ) | Landing pages, SaaS sites, product marketing |
|
||||
|
||||
### Cloudflare Variants
|
||||
|
||||
For deployment on Cloudflare Pages with D1 and R2, append `-cloudflare` to the template name:
|
||||
|
||||
```bash
|
||||
npm create astro@latest -- --template @emdash-cms/template-blog-cloudflare
|
||||
npm create astro@latest -- --template @emdash-cms/template-portfolio-cloudflare
|
||||
npm create astro@latest -- --template @emdash-cms/template-marketing-cloudflare
|
||||
```
|
||||
|
||||
These variants include `wrangler.jsonc` for deployment configuration.
|
||||
|
||||
## Customizing After Install
|
||||
|
||||
After the Setup Wizard completes, your site is a standard Astro project. Customize it like any Astro site:
|
||||
|
||||
- Edit pages in `src/pages/`
|
||||
- Modify layouts in `src/layouts/`
|
||||
- Add collections via the admin UI
|
||||
- Install Astro integrations
|
||||
- Deploy anywhere Astro runs
|
||||
|
||||
The seed file is only used during initial setup. Once applied, your schema lives in the database.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Creating Themes](/themes/creating-themes/)** — Build your own EmDash theme
|
||||
- **[Seed File Format](/themes/seed-files/)** — Reference for seed file structure
|
||||
- **[Getting Started](/getting-started/)** — Create your first EmDash site
|
||||
457
docs/src/content/docs/themes/porting-wp-themes.mdx
vendored
Normal file
@@ -0,0 +1,457 @@
|
||||
---
|
||||
title: Porting WordPress Themes
|
||||
description: Convert WordPress themes to EmDash themes using a structured approach
|
||||
---
|
||||
|
||||
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
WordPress themes can be systematically converted to EmDash. The visual design, content structure, and dynamic features all transfer using a three-phase approach.
|
||||
|
||||
<Aside type="tip" title="AI-Assisted Porting">
|
||||
AI coding agents excel at mechanical conversions like template porting. Feed the agent your
|
||||
WordPress theme files along with the concept mapping tables in this guide, and it can generate a
|
||||
solid first draft of Astro components. Review and refine the output—the agent handles the tedious
|
||||
parts while you focus on quality.
|
||||
</Aside>
|
||||
|
||||
## Three-Phase Approach
|
||||
|
||||
<Steps>
|
||||
|
||||
1. **Design Extraction**
|
||||
|
||||
Extract CSS variables, fonts, colors, and layout patterns from the WordPress theme. Analyze the live site to capture computed styles and responsive breakpoints.
|
||||
|
||||
2. **Template Conversion**
|
||||
|
||||
Convert PHP templates to Astro components. Map the WordPress template hierarchy to Astro routes and transform template tags to EmDash API calls.
|
||||
|
||||
3. **Dynamic Features**
|
||||
|
||||
Port navigation menus, widget areas, taxonomies, and site settings to their EmDash equivalents. Create a seed file to capture the complete content model.
|
||||
|
||||
</Steps>
|
||||
|
||||
## Phase 1: Design Extraction
|
||||
|
||||
### Locate CSS and Design Tokens
|
||||
|
||||
| File | Purpose |
|
||||
| ------------- | ------------------------------------------ |
|
||||
| `style.css` | Main stylesheet with theme header |
|
||||
| `assets/css/` | Additional stylesheets |
|
||||
| `theme.json` | Block themes (WP 5.9+) - structured tokens |
|
||||
|
||||
### Extract Design Tokens
|
||||
|
||||
| WordPress Pattern | EmDash Variable |
|
||||
| ----------------- | ------------------ |
|
||||
| Body font family | `--font-body` |
|
||||
| Heading font | `--font-heading` |
|
||||
| Primary color | `--color-primary` |
|
||||
| Background | `--color-base` |
|
||||
| Text color | `--color-contrast` |
|
||||
| Content width | `--content-width` |
|
||||
|
||||
### Create Base Layout
|
||||
|
||||
Create `src/layouts/Base.astro` with extracted CSS variables, header/footer structure, font loading, and responsive breakpoints:
|
||||
|
||||
```astro title="src/layouts/Base.astro"
|
||||
---
|
||||
import { getSiteSettings, getMenu } from "emdash";
|
||||
import "../styles/global.css";
|
||||
|
||||
const { title, description } = Astro.props;
|
||||
const settings = await getSiteSettings();
|
||||
const primaryMenu = await getMenu("primary");
|
||||
const pageTitle = title ? `${title} | ${settings.title}` : settings.title;
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{pageTitle}</title>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
{settings.logo ? (
|
||||
<img src={settings.logo.url} alt={settings.title} />
|
||||
) : (
|
||||
<span>{settings.title}</span>
|
||||
)}
|
||||
<nav>
|
||||
{primaryMenu?.items.map((item) => (
|
||||
<a href={item.url}>{item.label}</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main><slot /></main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Phase 2: Template Conversion
|
||||
|
||||
### Template Hierarchy Mapping
|
||||
|
||||
| WordPress Template | Astro Route |
|
||||
| --------------------------- | ----------------------------------- |
|
||||
| `index.php` | `src/pages/index.astro` |
|
||||
| `single.php` | `src/pages/posts/[slug].astro` |
|
||||
| `single-{post_type}.php` | `src/pages/{type}/[slug].astro` |
|
||||
| `page.php` | `src/pages/[...slug].astro` |
|
||||
| `archive.php` | `src/pages/posts/index.astro` |
|
||||
| `category.php` | `src/pages/categories/[slug].astro` |
|
||||
| `tag.php` | `src/pages/tags/[slug].astro` |
|
||||
| `search.php` | `src/pages/search.astro` |
|
||||
| `404.php` | `src/pages/404.astro` |
|
||||
| `header.php` / `footer.php` | Part of `src/layouts/Base.astro` |
|
||||
| `sidebar.php` | `src/components/Sidebar.astro` |
|
||||
|
||||
### Template Tags Mapping
|
||||
|
||||
| WordPress Function | EmDash Equivalent |
|
||||
| ----------------------------- | -------------------------------------------- |
|
||||
| `have_posts()` / `the_post()` | `getEmDashCollection()` |
|
||||
| `get_post()` | `getEmDashEntry()` |
|
||||
| `the_title()` | `post.data.title` |
|
||||
| `the_content()` | `<PortableText value={post.data.content} />` |
|
||||
| `the_excerpt()` | `post.data.excerpt` |
|
||||
| `the_permalink()` | `/posts/${post.slug}` |
|
||||
| `the_post_thumbnail()` | `post.data.featured_image` |
|
||||
| `get_the_date()` | `post.data.publishedAt` |
|
||||
| `get_the_category()` | `getEntryTerms(coll, id, "categories")` |
|
||||
| `get_the_tags()` | `getEntryTerms(coll, id, "tags")` |
|
||||
|
||||
### Converting The Loop
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="archive.php"
|
||||
<?php while (have_posts()) : the_post(); ?>
|
||||
<article>
|
||||
<h2><a href="<?php the_permalink(); ?>"><?php the_title(); ?></a></h2>
|
||||
<?php the_excerpt(); ?>
|
||||
</article>
|
||||
<?php endwhile; ?>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/pages/posts/index.astro"
|
||||
---
|
||||
import { getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { status: "published" },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
|
||||
---
|
||||
|
||||
<Base title="Blog">
|
||||
{posts.map((post) => (
|
||||
<article>
|
||||
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
|
||||
<p>{post.data.excerpt}</p>
|
||||
</article>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Converting Single Templates
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="single.php"
|
||||
<?php get_header(); ?>
|
||||
<article>
|
||||
<h1><?php the_title(); ?></h1>
|
||||
<?php the_content(); ?>
|
||||
<div class="post-meta">
|
||||
Posted in: <?php the_category(', '); ?>
|
||||
</div>
|
||||
</article>
|
||||
<?php get_footer(); ?>
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/pages/posts/[slug].astro"
|
||||
---
|
||||
import { getEmDashCollection, getEntryTerms } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const { entries: posts } = await getEmDashCollection("posts");
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
props: { post },
|
||||
}));
|
||||
}
|
||||
|
||||
const { post } = Astro.props;
|
||||
const categories = await getEntryTerms("posts", post.id, "categories");
|
||||
|
||||
---
|
||||
|
||||
<Base title={post.data.title}>
|
||||
<article>
|
||||
<h1>{post.data.title}</h1>
|
||||
<PortableText value={post.data.content} />
|
||||
<div class="post-meta">
|
||||
Posted in: {categories.map((cat) => (
|
||||
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</Base>
|
||||
```
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
### Converting Template Parts
|
||||
|
||||
WordPress `get_template_part()` calls become Astro component imports. The `template-parts/content-post.php` partial becomes a `PostCard.astro` component that you import and render in a loop.
|
||||
|
||||
## Phase 3: Dynamic Features
|
||||
|
||||
### Navigation Menus
|
||||
|
||||
Identify menus in `functions.php` and create corresponding EmDash menus:
|
||||
|
||||
```astro title="src/components/PrimaryNav.astro"
|
||||
---
|
||||
import { getMenu } from "emdash";
|
||||
|
||||
const menu = await getMenu("primary");
|
||||
---
|
||||
|
||||
{menu && (
|
||||
<nav class="primary-nav">
|
||||
<ul>
|
||||
{menu.items.map((item) => (
|
||||
<li>
|
||||
<a href={item.url} aria-current={Astro.url.pathname === item.url ? "page" : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
{item.children.length > 0 && (
|
||||
<ul class="submenu">
|
||||
{item.children.map((child) => (
|
||||
<li><a href={child.url}>{child.label}</a></li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
```
|
||||
|
||||
### Widget Areas (Sidebars)
|
||||
|
||||
Identify widget areas in the theme and render them:
|
||||
|
||||
```astro title="src/components/Sidebar.astro"
|
||||
---
|
||||
import { getWidgetArea, getMenu } from "emdash";
|
||||
import { PortableText } from "emdash/ui";
|
||||
import RecentPosts from "./widgets/RecentPosts.astro";
|
||||
|
||||
const sidebar = await getWidgetArea("sidebar");
|
||||
const widgetComponents = { "core:recent-posts": RecentPosts };
|
||||
---
|
||||
|
||||
{sidebar && sidebar.widgets.length > 0 && (
|
||||
<aside class="sidebar">
|
||||
{sidebar.widgets.map(async (widget) => (
|
||||
<div class="widget">
|
||||
{widget.title && <h3>{widget.title}</h3>}
|
||||
{widget.type === "content" && <PortableText value={widget.content} />}
|
||||
{widget.type === "menu" && (
|
||||
<nav>
|
||||
{await getMenu(widget.menuName).then((m) =>
|
||||
m?.items.map((item) => <a href={item.url}>{item.label}</a>)
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
{widget.type === "component" && widgetComponents[widget.componentId] && (
|
||||
<Fragment>
|
||||
{(() => {
|
||||
const Component = widgetComponents[widget.componentId];
|
||||
return <Component {...widget.componentProps} />;
|
||||
})()}
|
||||
</Fragment>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
```
|
||||
|
||||
### Widget Type Mapping
|
||||
|
||||
| WordPress Widget | EmDash Widget Type |
|
||||
| ---------------- | -------------------------------- |
|
||||
| Text/Custom HTML | `type: "content"` |
|
||||
| Custom Menu | `type: "menu"` |
|
||||
| Recent Posts | `component: "core:recent-posts"` |
|
||||
| Categories | `component: "core:categories"` |
|
||||
| Tag Cloud | `component: "core:tag-cloud"` |
|
||||
| Search | `component: "core:search"` |
|
||||
|
||||
### Taxonomies
|
||||
|
||||
Query taxonomies registered in the theme:
|
||||
|
||||
```astro title="src/pages/genres/[slug].astro"
|
||||
---
|
||||
import { getTaxonomyTerms, getEntriesByTerm } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const genres = await getTaxonomyTerms("genre");
|
||||
return genres.map((genre) => ({
|
||||
params: { slug: genre.slug },
|
||||
props: { genre },
|
||||
}));
|
||||
}
|
||||
|
||||
const { genre } = Astro.props;
|
||||
const books = await getEntriesByTerm("books", "genre", genre.slug);
|
||||
---
|
||||
|
||||
<Base title={genre.label}>
|
||||
<h1>{genre.label}</h1>
|
||||
{books.map((book) => (
|
||||
<article>
|
||||
<h2><a href={`/books/${book.slug}`}>{book.data.title}</a></h2>
|
||||
</article>
|
||||
))}
|
||||
</Base>
|
||||
```
|
||||
|
||||
### Site Settings Mapping
|
||||
|
||||
| WordPress Customizer | EmDash Setting |
|
||||
| -------------------- | ---------------- |
|
||||
| Site Title | `title` |
|
||||
| Tagline | `tagline` |
|
||||
| Site Icon | `favicon` |
|
||||
| Custom Logo | `logo` |
|
||||
| Posts per page | `postsPerPage` |
|
||||
|
||||
## Shortcodes to Portable Text
|
||||
|
||||
WordPress shortcodes become Portable Text custom blocks:
|
||||
|
||||
<Tabs>
|
||||
<TabItem label="WordPress">
|
||||
```php title="functions.php"
|
||||
add_shortcode('gallery', function($atts) {
|
||||
$ids = explode(',', $atts['ids']);
|
||||
return '<div class="gallery">...</div>';
|
||||
});
|
||||
```
|
||||
</TabItem>
|
||||
<TabItem label="EmDash">
|
||||
```astro title="src/components/blocks/Gallery.astro"
|
||||
---
|
||||
const { images } = Astro.props;
|
||||
---
|
||||
|
||||
<div class="gallery">
|
||||
{images.map((img) => (
|
||||
<img src={img.url} alt={img.alt || ""} loading="lazy" />
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
Register with PortableText:
|
||||
|
||||
```astro
|
||||
<PortableText value={content} components={{ gallery: Gallery }} />
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
## Seed File Structure
|
||||
|
||||
Capture the complete content model in a seed file. Include settings, taxonomies, menus, and widget areas:
|
||||
|
||||
```json title=".emdash/seed.json"
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": { "name": "Ported Theme" },
|
||||
"settings": { "title": "My Site", "tagline": "Welcome", "postsPerPage": 10 },
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"]
|
||||
}
|
||||
],
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "Blog", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"widgetAreas": [
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Main Sidebar",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:recent-posts",
|
||||
"props": { "count": 5 }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
See [Seed File Format](/themes/seed-files/) for the complete specification.
|
||||
|
||||
## Porting Checklist
|
||||
|
||||
**Phase 1 (Design):** CSS variables extracted, fonts loading, color scheme matches, responsive breakpoints work.
|
||||
|
||||
**Phase 2 (Templates):** Homepage, single posts, archives, and 404 page all render correctly.
|
||||
|
||||
**Phase 3 (Dynamic):** Site settings configured, menus functional, taxonomies queryable, widget areas rendering, seed file complete.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
### Child Themes
|
||||
|
||||
If the theme has a parent (check `style.css` for `Template:`), analyze the parent theme first, then apply child theme overrides.
|
||||
|
||||
### Block Themes (FSE)
|
||||
|
||||
WordPress 5.9+ block themes use `theme.json` for design tokens and `templates/*.html` for block markup. Convert block markup to Astro components and extract tokens from `theme.json`.
|
||||
|
||||
### Page Builders
|
||||
|
||||
Content built with Elementor, Divi, or similar is stored in post meta, not theme files. This content imports via WXR, not theme porting. Focus theme porting on the shell—page builder content renders through Portable Text after import.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Creating Themes](/themes/creating-themes/)** — Build distributable EmDash themes
|
||||
- **[Seed File Format](/themes/seed-files/)** — Complete seed file specification
|
||||
- **[Migrate from WordPress](/migration/from-wordpress/)** — Import WordPress content
|
||||
679
docs/src/content/docs/themes/seed-files.mdx
vendored
Normal file
@@ -0,0 +1,679 @@
|
||||
---
|
||||
title: Seed File Format
|
||||
description: Reference for EmDash seed file structure and syntax.
|
||||
---
|
||||
|
||||
import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";
|
||||
|
||||
Seed files are JSON documents that bootstrap EmDash sites. They define collections, fields, taxonomies, menus, redirects, widget areas, site settings, and optional sample content.
|
||||
|
||||
## Root Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {},
|
||||
"settings": {},
|
||||
"collections": [],
|
||||
"taxonomies": [],
|
||||
"bylines": [],
|
||||
"menus": [],
|
||||
"redirects": [],
|
||||
"widgetAreas": [],
|
||||
"sections": [],
|
||||
"content": {}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ------------- | -------- | -------- | ------------------------------------- |
|
||||
| `$schema` | `string` | No | JSON schema URL for editor validation |
|
||||
| `version` | `"1"` | Yes | Seed format version |
|
||||
| `meta` | `object` | No | Metadata about the seed |
|
||||
| `settings` | `object` | No | Site settings |
|
||||
| `collections` | `array` | No | Collection definitions |
|
||||
| `taxonomies` | `array` | No | Taxonomy definitions |
|
||||
| `bylines` | `array` | No | Byline profile definitions |
|
||||
| `menus` | `array` | No | Navigation menus |
|
||||
| `redirects` | `array` | No | Redirect rules |
|
||||
| `widgetAreas` | `array` | No | Widget area definitions |
|
||||
| `sections` | `array` | No | Reusable content blocks |
|
||||
| `content` | `object` | No | Sample content entries |
|
||||
|
||||
## Meta
|
||||
|
||||
Optional metadata about the seed:
|
||||
|
||||
```json
|
||||
{
|
||||
"meta": {
|
||||
"name": "Blog Starter",
|
||||
"description": "A simple blog with posts, pages, and categories",
|
||||
"author": "EmDash"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Settings
|
||||
|
||||
Site-wide configuration values:
|
||||
|
||||
```json
|
||||
{
|
||||
"settings": {
|
||||
"title": "My Site",
|
||||
"tagline": "A modern CMS",
|
||||
"postsPerPage": 10,
|
||||
"dateFormat": "MMMM d, yyyy"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Settings are applied to the `options` table with the `site:` prefix. The Setup Wizard will prefill `title` and `tagline` from the seed file (if provided), allowing users to override them during initial setup.
|
||||
|
||||
## Collections
|
||||
|
||||
Collection definitions create content types in the database:
|
||||
|
||||
```json
|
||||
{
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"description": "Blog posts",
|
||||
"icon": "file-text",
|
||||
"supports": ["drafts", "revisions"],
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText"
|
||||
},
|
||||
{
|
||||
"slug": "featured_image",
|
||||
"label": "Featured Image",
|
||||
"type": "image"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Collection Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| --------------- | -------- | -------- | -------------------------------------------- |
|
||||
| `slug` | `string` | Yes | URL-safe identifier (lowercase, underscores) |
|
||||
| `label` | `string` | Yes | Plural display name |
|
||||
| `labelSingular` | `string` | No | Singular display name |
|
||||
| `description` | `string` | No | Admin UI description |
|
||||
| `icon` | `string` | No | Lucide icon name |
|
||||
| `supports` | `array` | No | Features: `"drafts"`, `"revisions"` |
|
||||
| `fields` | `array` | Yes | Field definitions |
|
||||
|
||||
### Field Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| -------------- | --------- | -------- | ------------------------------------ |
|
||||
| `slug` | `string` | Yes | Column name (lowercase, underscores) |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `type` | `string` | Yes | Field type |
|
||||
| `required` | `boolean` | No | Validation: field must have a value |
|
||||
| `unique` | `boolean` | No | Validation: value must be unique |
|
||||
| `defaultValue` | `any` | No | Default value for new entries |
|
||||
| `validation` | `object` | No | Additional validation rules |
|
||||
| `widget` | `string` | No | Admin UI widget override |
|
||||
| `options` | `object` | No | Widget-specific configuration |
|
||||
|
||||
### Field Types
|
||||
|
||||
| Type | Description | Stored As |
|
||||
| -------------- | -------------------------- | ----------------- |
|
||||
| `string` | Short text | `TEXT` |
|
||||
| `text` | Long text (textarea) | `TEXT` |
|
||||
| `number` | Numeric value | `REAL` |
|
||||
| `integer` | Whole number | `INTEGER` |
|
||||
| `boolean` | True/false | `INTEGER` |
|
||||
| `date` | Date value | `TEXT` (ISO 8601) |
|
||||
| `datetime` | Date and time | `TEXT` (ISO 8601) |
|
||||
| `email` | Email address | `TEXT` |
|
||||
| `url` | URL | `TEXT` |
|
||||
| `slug` | URL-safe string | `TEXT` |
|
||||
| `portableText` | Rich text content | `JSON` |
|
||||
| `image` | Image reference | `JSON` |
|
||||
| `file` | File reference | `JSON` |
|
||||
| `json` | Arbitrary JSON | `JSON` |
|
||||
| `reference` | Reference to another entry | `TEXT` |
|
||||
|
||||
## Taxonomies
|
||||
|
||||
Classification systems for content:
|
||||
|
||||
```json
|
||||
{
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "news", "label": "News" },
|
||||
{ "slug": "tutorials", "label": "Tutorials" },
|
||||
{
|
||||
"slug": "advanced",
|
||||
"label": "Advanced Tutorials",
|
||||
"parent": "tutorials"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"label": "Tags",
|
||||
"labelSingular": "Tag",
|
||||
"hierarchical": false,
|
||||
"collections": ["posts"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Taxonomy Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| --------------- | --------- | -------- | ---------------------------------------------- |
|
||||
| `name` | `string` | Yes | Unique identifier |
|
||||
| `label` | `string` | Yes | Plural display name |
|
||||
| `labelSingular` | `string` | No | Singular display name |
|
||||
| `hierarchical` | `boolean` | Yes | Allow nested terms (categories) or flat (tags) |
|
||||
| `collections` | `array` | Yes | Collections this taxonomy applies to |
|
||||
| `terms` | `array` | No | Pre-defined terms |
|
||||
|
||||
### Term Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------- | -------- | -------- | ------------------------------------ |
|
||||
| `slug` | `string` | Yes | URL-safe identifier |
|
||||
| `label` | `string` | Yes | Display name |
|
||||
| `description` | `string` | No | Term description |
|
||||
| `parent` | `string` | No | Parent term slug (hierarchical only) |
|
||||
|
||||
## Menus
|
||||
|
||||
Navigation menus editable from the admin:
|
||||
|
||||
```json
|
||||
{
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "page", "ref": "about" },
|
||||
{ "type": "custom", "label": "Blog", "url": "/posts" },
|
||||
{
|
||||
"type": "custom",
|
||||
"label": "External",
|
||||
"url": "https://example.com",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Menu Item Types
|
||||
|
||||
| Type | Description | Required Fields |
|
||||
| ------------ | ---------------------------- | ------------------- |
|
||||
| `custom` | Custom URL | `url` |
|
||||
| `page` | Link to a page entry | `ref` |
|
||||
| `post` | Link to a post entry | `ref` |
|
||||
| `taxonomy` | Link to a taxonomy archive | `ref`, `collection` |
|
||||
| `collection` | Link to a collection archive | `collection` |
|
||||
|
||||
### Menu Item Properties
|
||||
|
||||
| Property | Type | Description |
|
||||
| ------------ | -------- | ------------------------------------------------ |
|
||||
| `type` | `string` | Item type (see above) |
|
||||
| `label` | `string` | Display text (auto-generated for page/post refs) |
|
||||
| `url` | `string` | Custom URL (for `custom` type) |
|
||||
| `ref` | `string` | Content ID in seed (for `page`/`post` types) |
|
||||
| `collection` | `string` | Collection slug |
|
||||
| `target` | `string` | `"_blank"` for new window |
|
||||
| `titleAttr` | `string` | HTML title attribute |
|
||||
| `cssClasses` | `string` | Custom CSS classes |
|
||||
| `children` | `array` | Nested menu items |
|
||||
|
||||
## Bylines
|
||||
|
||||
Byline profiles are separate from ownership (`author_id`). Define reusable byline identities once, then reference them from content entries.
|
||||
|
||||
```json
|
||||
{
|
||||
"bylines": [
|
||||
{
|
||||
"id": "editorial",
|
||||
"slug": "emdash-editorial",
|
||||
"displayName": "EmDash Editorial"
|
||||
},
|
||||
{
|
||||
"id": "guest",
|
||||
"slug": "guest-contributor",
|
||||
"displayName": "Guest Contributor",
|
||||
"isGuest": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------ | --------- | -------- | ------------------------------------------ |
|
||||
| `id` | `string` | Yes | Seed-local ID used by `content[].bylines` |
|
||||
| `slug` | `string` | Yes | URL-safe byline slug |
|
||||
| `displayName`| `string` | Yes | Name shown in templates and APIs |
|
||||
| `bio` | `string` | No | Optional profile bio |
|
||||
| `websiteUrl` | `string` | No | Optional website URL |
|
||||
| `isGuest` | `boolean` | No | Marks byline as guest profile |
|
||||
|
||||
## Redirects
|
||||
|
||||
Redirect rules to preserve legacy URLs after migration:
|
||||
|
||||
```json
|
||||
{
|
||||
"redirects": [
|
||||
{ "source": "/old-about", "destination": "/about" },
|
||||
{ "source": "/legacy-feed", "destination": "/rss.xml", "type": 308 },
|
||||
{
|
||||
"source": "/category/news",
|
||||
"destination": "/categories/news",
|
||||
"groupName": "migration"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Redirect Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------- | --------- | -------- | -------------------------------------------------- |
|
||||
| `source` | `string` | Yes | Source path (must start with `/`) |
|
||||
| `destination` | `string` | Yes | Destination path (must start with `/`) |
|
||||
| `type` | `number` | No | HTTP status: `301`, `302`, `307`, or `308` |
|
||||
| `enabled` | `boolean` | No | Whether the redirect is active (default: `true`) |
|
||||
| `groupName` | `string` | No | Optional grouping label for admin filtering/search |
|
||||
|
||||
<Aside type="caution">
|
||||
`source` and `destination` must be local paths. External URLs, protocol-relative paths (`//...`),
|
||||
path traversal segments (`..`), and newline characters are rejected by seed validation.
|
||||
</Aside>
|
||||
|
||||
## Widget Areas
|
||||
|
||||
Configurable content regions:
|
||||
|
||||
```json
|
||||
{
|
||||
"widgetAreas": [
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Main Sidebar",
|
||||
"description": "Appears on blog posts and pages",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"title": "Recent Posts",
|
||||
"componentId": "core:recent-posts",
|
||||
"props": { "count": 5 }
|
||||
},
|
||||
{
|
||||
"type": "menu",
|
||||
"title": "Quick Links",
|
||||
"menuName": "footer"
|
||||
},
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Welcome to our site!" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Widget Types
|
||||
|
||||
| Type | Description | Required Fields |
|
||||
| ----------- | -------------------- | ------------------------- |
|
||||
| `content` | Rich text content | `content` (Portable Text) |
|
||||
| `menu` | Renders a menu | `menuName` |
|
||||
| `component` | Registered component | `componentId` |
|
||||
|
||||
### Built-in Components
|
||||
|
||||
| Component ID | Description |
|
||||
| ------------------- | -------------------- |
|
||||
| `core:recent-posts` | List of recent posts |
|
||||
| `core:categories` | Category list |
|
||||
| `core:tags` | Tag cloud |
|
||||
| `core:search` | Search form |
|
||||
| `core:archives` | Monthly archives |
|
||||
|
||||
## Sections
|
||||
|
||||
Reusable content blocks that editors can insert into Portable Text fields via the `/section` slash command:
|
||||
|
||||
```json
|
||||
{
|
||||
"sections": [
|
||||
{
|
||||
"slug": "hero-centered",
|
||||
"title": "Centered Hero",
|
||||
"description": "Full-width hero with centered heading and CTA button",
|
||||
"keywords": ["hero", "banner", "header", "landing"],
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h1",
|
||||
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"children": [
|
||||
{ "_type": "span", "text": "Your compelling tagline goes here." }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Section Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------- | -------- | -------- | ----------------------------------------------- |
|
||||
| `slug` | `string` | Yes | URL-safe identifier |
|
||||
| `title` | `string` | Yes | Display name shown in the section picker |
|
||||
| `description` | `string` | No | Explains when to use this section |
|
||||
| `keywords` | `array` | No | Search terms for finding the section |
|
||||
| `content` | `array` | Yes | Portable Text blocks |
|
||||
| `source` | `string` | No | `"theme"` (default for seeds) or `"import"` |
|
||||
|
||||
Sections from seed files are marked `source: "theme"` and cannot be deleted from the admin UI. Editors can create their own sections (`source: "user"`) and insert any section type when editing content.
|
||||
|
||||
## Content
|
||||
|
||||
Sample content organized by collection:
|
||||
|
||||
```json
|
||||
{
|
||||
"content": {
|
||||
"posts": [
|
||||
{
|
||||
"id": "hello-world",
|
||||
"slug": "hello-world",
|
||||
"status": "published",
|
||||
"bylines": [
|
||||
{ "byline": "editorial" },
|
||||
{ "byline": "guest", "roleLabel": "Guest essay" }
|
||||
],
|
||||
"data": {
|
||||
"title": "Hello World",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "Welcome!" }]
|
||||
}
|
||||
],
|
||||
"excerpt": "Your first post."
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["news"],
|
||||
"tag": ["welcome", "first-post"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"id": "about",
|
||||
"slug": "about",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "About Us",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [{ "_type": "span", "text": "About page content." }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Content Entry Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ------------ | -------- | -------- | --------------------------------------------------- |
|
||||
| `id` | `string` | Yes | Seed-local ID for references |
|
||||
| `slug` | `string` | Yes | URL slug |
|
||||
| `status` | `string` | No | `"published"` or `"draft"` (default: `"published"`) |
|
||||
| `data` | `object` | Yes | Field values |
|
||||
| `bylines` | `array` | No | Ordered byline credits (`byline`, optional `roleLabel`) |
|
||||
| `taxonomies` | `object` | No | Term assignments by taxonomy name |
|
||||
|
||||
## Content References
|
||||
|
||||
Reference other content entries using the `$ref:` prefix:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"related_posts": ["$ref:another-post", "$ref:third-post"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `$ref:` prefix resolves seed IDs to database IDs during seeding.
|
||||
|
||||
## Media References
|
||||
|
||||
Include images from URLs:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-xxx",
|
||||
"alt": "Description of the image",
|
||||
"filename": "hero.jpg",
|
||||
"caption": "Photo by Someone"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Include local images from `.emdash/media/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"file": "hero.jpg",
|
||||
"alt": "Description of the image"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Media Properties
|
||||
|
||||
| Property | Type | Required | Description |
|
||||
| ---------- | -------- | -------- | ------------------------------------ |
|
||||
| `url` | `string` | Yes\* | Remote URL to download |
|
||||
| `file` | `string` | Yes\* | Local filename in `.emdash/media/` |
|
||||
| `alt` | `string` | No | Alt text for accessibility |
|
||||
| `filename` | `string` | No | Override filename |
|
||||
| `caption` | `string` | No | Media caption |
|
||||
|
||||
\*Either `url` or `file` is required, not both.
|
||||
|
||||
<Aside>
|
||||
Media is downloaded during seeding. Large images may slow down the Setup Wizard. Consider using
|
||||
compressed images or thumbnail versions for sample content.
|
||||
</Aside>
|
||||
|
||||
## Applying Seeds Programmatically
|
||||
|
||||
Use the seed API for CLI tools or scripts:
|
||||
|
||||
```typescript
|
||||
import { applySeed, validateSeed } from "emdash/seed";
|
||||
import seedData from "./.emdash/seed.json";
|
||||
|
||||
// Validate first
|
||||
const validation = validateSeed(seedData);
|
||||
if (!validation.valid) {
|
||||
console.error(validation.errors);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Apply seed
|
||||
const result = await applySeed(db, seedData, {
|
||||
includeContent: true,
|
||||
onConflict: "skip",
|
||||
storage: myStorage,
|
||||
baseUrl: "http://localhost:4321",
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
// {
|
||||
// collections: { created: 2, skipped: 0 },
|
||||
// fields: { created: 8, skipped: 0 },
|
||||
// taxonomies: { created: 2, terms: 5 },
|
||||
// bylines: { created: 2, skipped: 0 },
|
||||
// menus: { created: 1, items: 4 },
|
||||
// redirects: { created: 3, skipped: 0 },
|
||||
// widgetAreas: { created: 1, widgets: 3 },
|
||||
// settings: { applied: 3 },
|
||||
// content: { created: 3, skipped: 0 },
|
||||
// media: { created: 2, skipped: 0 }
|
||||
// }
|
||||
```
|
||||
|
||||
### Apply Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ---------------- | --------- | -------- | ---------------------------------- |
|
||||
| `includeContent` | `boolean` | `false` | Create sample content entries |
|
||||
| `onConflict` | `string` | `"skip"` | `"skip"`, `"update"`, or `"error"` |
|
||||
| `mediaBasePath` | `string` | — | Base path for local media files |
|
||||
| `storage` | `Storage` | — | Storage adapter for media uploads |
|
||||
| `baseUrl` | `string` | — | Base URL for media URLs |
|
||||
|
||||
## Idempotency
|
||||
|
||||
Seeding is safe to run multiple times. Conflict behavior by entity type:
|
||||
|
||||
| Entity | Behavior |
|
||||
| ------------------- | ------------------------------------- |
|
||||
| Collection | Skip if slug exists |
|
||||
| Field | Skip if collection + slug exists |
|
||||
| Taxonomy definition | Skip if name exists |
|
||||
| Taxonomy term | Skip if name + slug exists |
|
||||
| Byline profile | Skip if slug exists |
|
||||
| Menu | Skip if name exists |
|
||||
| Menu items | Replace all (menu is recreated) |
|
||||
| Redirect | Skip if source exists |
|
||||
| Widget area | Skip if name exists |
|
||||
| Widgets | Replace all (area is recreated) |
|
||||
| Section | Skip if slug exists |
|
||||
| Settings | Update (settings are meant to change) |
|
||||
| Content | Skip if slug exists in collection |
|
||||
|
||||
<Aside type="caution">
|
||||
Menu items and widgets are **replaced**, not merged. The seed file is the source of truth for menu
|
||||
and widget area structure.
|
||||
</Aside>
|
||||
|
||||
## Validation
|
||||
|
||||
Seed files are validated before application:
|
||||
|
||||
```typescript
|
||||
import { validateSeed } from "emdash/seed";
|
||||
|
||||
const { valid, errors, warnings } = validateSeed(seedData);
|
||||
|
||||
if (!valid) {
|
||||
errors.forEach((e) => console.error(e));
|
||||
}
|
||||
|
||||
warnings.forEach((w) => console.warn(w));
|
||||
```
|
||||
|
||||
Validation checks:
|
||||
|
||||
- Required fields are present
|
||||
- Slugs follow naming conventions (lowercase, underscores)
|
||||
- Field types are valid
|
||||
- References point to existing content
|
||||
- Hierarchical term parents exist
|
||||
- Redirect paths are safe local URLs
|
||||
- Redirect sources are unique
|
||||
- No duplicate slugs within collections
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Apply seed file
|
||||
npx emdash seed .emdash/seed.json
|
||||
|
||||
# Apply without sample content
|
||||
npx emdash seed .emdash/seed.json --no-content
|
||||
|
||||
# Validate only
|
||||
npx emdash seed .emdash/seed.json --validate
|
||||
|
||||
# Export current schema as seed
|
||||
npx emdash export-seed > seed.json
|
||||
|
||||
# Export with content
|
||||
npx emdash export-seed --with-content > seed.json
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- **[Creating Themes](/themes/creating-themes/)** — Build a complete theme
|
||||
- **[Themes Overview](/themes/overview/)** — How themes work
|
||||
102
docs/src/content/docs/why-emdash.mdx
Normal file
@@ -0,0 +1,102 @@
|
||||
---
|
||||
title: Why EmDash?
|
||||
description: Understand what problems EmDash solves and how it compares to other approaches.
|
||||
---
|
||||
|
||||
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
|
||||
|
||||
EmDash is an Astro-native CMS that combines traditional CMS patterns with modern web development: a content editing interface, Astro framework integration, and flexible deployment options.
|
||||
|
||||
## What Makes EmDash Different
|
||||
|
||||
### Astro-Native Architecture
|
||||
|
||||
EmDash is built specifically for Astro, not adapted from a generic CMS. Content lives in the same deployment as your site, queried through Astro's Live Content Collections. No separate services, no API round-trips, no webhook synchronization.
|
||||
|
||||
### Familiar Content Model
|
||||
|
||||
If you've used WordPress, EmDash's concepts will feel familiar: collections (like post types), taxonomies, menus, widget areas, and a media library. The mental model transfers—the implementation uses modern tooling.
|
||||
|
||||
### Framework Integration
|
||||
|
||||
EmDash is purpose-built for Astro. This tight integration enables type-safe queries, component-level caching, and integrated preview.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Single Deployment" icon="rocket">
|
||||
Content and frontend deploy together. One codebase, one deployment, one system to manage.
|
||||
</Card>
|
||||
<Card title="Type Safety" icon="approve-check">
|
||||
Schema lives in the database. TypeScript types flow from database to template with full
|
||||
autocomplete.
|
||||
</Card>
|
||||
<Card title="Live Updates" icon="star">
|
||||
Built on Astro's Live Content Collections. Content changes appear instantly—no rebuilds
|
||||
needed.
|
||||
</Card>
|
||||
<Card title="Cloud-Portable" icon="setting">
|
||||
Runs on Cloudflare Workers with D1 and R2, and also works with Node.js, SQLite, and any
|
||||
S3-compatible storage.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
## How It Compares
|
||||
|
||||
Different CMS approaches suit different needs:
|
||||
|
||||
| Aspect | Traditional CMS | Headless CMS | EmDash |
|
||||
| ------------------- | ---------------- | --------------- | --------------------- |
|
||||
| **Architecture** | Monolithic | Decoupled | Integrated with Astro |
|
||||
| **Content editing** | Built-in admin | Built-in admin | Built-in admin |
|
||||
| **Frontend** | Themes/templates | Bring your own | Astro components |
|
||||
| **Deployment** | Single server | CMS + frontend | Single deployment |
|
||||
| **Type safety** | Runtime | API types | Full TypeScript |
|
||||
| **Content updates** | Immediate | Webhook/rebuild | Immediate (SSR) |
|
||||
| **Plugin model** | Same-process | API extensions | Sandboxed with hooks |
|
||||
|
||||
## Cloudflare Deployment
|
||||
|
||||
EmDash runs on any platform with SQLite and S3-compatible storage. It also supports Cloudflare-specific features:
|
||||
|
||||
- **D1** — SQLite at the edge with automatic replication
|
||||
- **R2** — S3-compatible storage with no egress fees
|
||||
- **Workers** — Global deployment with fast cold starts
|
||||
|
||||
## Plugin Migration
|
||||
|
||||
EmDash provides tools to help migrate WordPress plugin functionality:
|
||||
|
||||
- **Concept mapping** — WordPress hooks, filters, and APIs map to EmDash equivalents
|
||||
- **Migration guides** — Documentation for porting specific plugin patterns
|
||||
- **AI-assisted porting** — Documentation structured to help AI tools generate EmDash plugins from WordPress plugin code
|
||||
|
||||
Complex plugins still need human review, but for straightforward plugins, the migration guides reduce porting effort.
|
||||
|
||||
## When to Use EmDash
|
||||
|
||||
**EmDash is designed for:**
|
||||
|
||||
- New Astro projects that need a CMS
|
||||
- WordPress migrations where you want modern tooling
|
||||
- Sites with content editors who shouldn't touch code
|
||||
- Projects deploying to Cloudflare
|
||||
- Sites where type safety and developer experience matter
|
||||
|
||||
**EmDash may not be right for:**
|
||||
|
||||
- Non-Astro projects (it's tightly coupled to Astro)
|
||||
- E-commerce (WooCommerce-scale features are not yet available)
|
||||
- Existing headless architectures you're happy with
|
||||
- Projects requiring WordPress's specific plugin ecosystem
|
||||
|
||||
## Get Started
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Quick Start" icon="rocket">
|
||||
[Create your first site](/getting-started/) in under 5 minutes.
|
||||
</Card>
|
||||
<Card title="Migration Guide" icon="right-arrow">
|
||||
[Migrate from WordPress](/migration/from-wordpress/) with content import and concept mapping.
|
||||
</Card>
|
||||
</CardGrid>
|
||||
31
docs/src/styles/custom.css
Normal file
@@ -0,0 +1,31 @@
|
||||
/* EmDash Docs Custom Styles */
|
||||
|
||||
:root {
|
||||
/* Brand colors */
|
||||
--sl-color-accent-low: #1a1a2e;
|
||||
--sl-color-accent: #4a6cf7;
|
||||
--sl-color-accent-high: #7b91f7;
|
||||
|
||||
/* Typography refinements */
|
||||
--sl-font:
|
||||
system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
|
||||
"Open Sans", "Helvetica Neue", sans-serif;
|
||||
--sl-font-mono:
|
||||
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--sl-color-accent-low: #1a1a2e;
|
||||
--sl-color-accent: #6b7fff;
|
||||
--sl-color-accent-high: #a3b0ff;
|
||||
}
|
||||
|
||||
/* Improve code block readability */
|
||||
.expressive-code {
|
||||
margin-block: 1.5rem;
|
||||
}
|
||||
|
||||
/* Card styling for callouts */
|
||||
.starlight-aside {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
86
docs/src/worker.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { handle } from "@astrojs/cloudflare/handler";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { createMcpHandler } from "agents/mcp";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Build a fresh McpServer per request. createMcpHandler is stateless, and the
|
||||
* underlying transport asserts that the server is not already connected, so we
|
||||
* cannot reuse a single server instance across requests.
|
||||
*/
|
||||
function buildMcpServer(env: Env): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "emdash-docs",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"search_docs",
|
||||
{
|
||||
title: "Search EmDash documentation",
|
||||
description:
|
||||
"Search the EmDash CMS documentation. Returns relevant chunks with source URLs and similarity scores.",
|
||||
inputSchema: {
|
||||
query: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.describe("Natural-language query against the EmDash docs."),
|
||||
max_results: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(20)
|
||||
.optional()
|
||||
.describe("Maximum number of chunks to return. Defaults to 8."),
|
||||
},
|
||||
},
|
||||
async ({ query, max_results }) => {
|
||||
const limit = max_results ?? 8;
|
||||
|
||||
const results = await env.AI_SEARCH.search({
|
||||
messages: [{ role: "user", content: query }],
|
||||
ai_search_options: {
|
||||
retrieval: { max_num_results: limit },
|
||||
},
|
||||
});
|
||||
|
||||
if (!results.chunks.length) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "No matching docs found.",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: results.chunks.map((chunk) => {
|
||||
const source = chunk.item.key;
|
||||
const score = typeof chunk.score === "number" ? chunk.score.toFixed(3) : "n/a";
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: `<result source="${source}" score="${score}">\n${chunk.text}\n</result>`,
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/mcp") {
|
||||
const handler = createMcpHandler(buildMcpServer(env), { route: "/mcp" });
|
||||
return handler(request, env, ctx);
|
||||
}
|
||||
|
||||
return handle(request, env, ctx);
|
||||
},
|
||||
} satisfies ExportedHandler<Env>;
|
||||
5
docs/tsconfig.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*", "./worker-configuration.d.ts"],
|
||||
"exclude": ["dist"]
|
||||
}
|
||||
14056
docs/worker-configuration.d.ts
vendored
Normal file
28
docs/wrangler.jsonc
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"compatibility_date": "2026-03-01",
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "docs.emdashcms.com",
|
||||
"custom_domain": true,
|
||||
"zone_name": "emdashcms.com",
|
||||
},
|
||||
],
|
||||
"compatibility_flags": ["global_fetch_strictly_public", "nodejs_compat"],
|
||||
"name": "docs",
|
||||
"main": "./src/worker.ts",
|
||||
"assets": {
|
||||
"directory": "./dist",
|
||||
"binding": "ASSETS",
|
||||
},
|
||||
"ai_search": [
|
||||
{
|
||||
"remote": true,
|
||||
"binding": "AI_SEARCH",
|
||||
"instance_name": "emdash-docs",
|
||||
},
|
||||
],
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||