first commit

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

21
docs/.gitignore vendored Normal file
View File

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

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

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

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

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

15
docs/README.md Normal file
View File

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

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

@@ -0,0 +1,170 @@
import starlight from "@astrojs/starlight";
// @ts-check
import { defineConfig } from "astro/config";
// https://astro.build/config
export default defineConfig({
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/withastro/emdash",
},
{
icon: "discord",
label: "Discord",
href: "https://astro.build/chat",
},
],
editLink: {
baseUrl: "https://github.com/withastro/emdash/edit/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: "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: "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: "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: "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" },
],
},
],
}),
],
});

24
docs/package.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "docs",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro"
},
"dependencies": {
"@astrojs/starlight": "^0.37.4",
"@astrojs/starlight-tailwind": "^4.0.2",
"astro": "^5.6.1",
"sharp": "^0.34.5",
"starlight-utils": "^1.0.0",
"tailwindcss": "^4.1.18",
"wrangler": "^4.63.0"
},
"devDependencies": {},
"peerDependencies": {},
"optionalDependencies": {}
}

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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill-rule="evenodd" d="M81 36 64 0 47 36l-1 2-9-10a6 6 0 0 0-9 9l10 10h-2L0 64l36 17h2L28 91a6 6 0 1 0 9 9l9-10 1 2 17 36 17-36v-2l9 10a6 6 0 1 0 9-9l-9-9 2-1 36-17-36-17-2-1 9-9a6 6 0 1 0-9-9l-9 10v-2Zm-17 2-2 5c-4 8-11 15-19 19l-5 2 5 2c8 4 15 11 19 19l2 5 2-5c4-8 11-15 19-19l5-2-5-2c-8-4-15-11-19-19l-2-5Z" clip-rule="evenodd"/><path d="M118 19a6 6 0 0 0-9-9l-3 3a6 6 0 1 0 9 9l3-3Zm-96 4c-2 2-6 2-9 0l-3-3a6 6 0 1 1 9-9l3 3c3 2 3 6 0 9Zm0 82c-2-2-6-2-9 0l-3 3a6 6 0 1 0 9 9l3-3c3-2 3-6 0-9Zm96 4a6 6 0 0 1-9 9l-3-3a6 6 0 1 1 9-9l3 3Z"/><style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style></svg>

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -0,0 +1,8 @@
<svg width="140" height="32" viewBox="0 0 140 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Dizzy emoji (Twemoji) scaled to fit -->
<g transform="translate(0, 2) scale(0.78)">
<path fill="#FDD888" d="M28.865 7.134c7.361 7.359 9.35 17.304 4.443 22.209-4.907 4.907-14.85 2.918-22.21-4.441-.25-.25-.478-.51-.716-.766l4.417-4.417c5.724 5.724 13.016 7.714 16.286 4.442 3.271-3.271 1.282-10.563-4.441-16.287l.022.021-.021-.022C20.104 1.331 11.154-.326 6.657 4.171 4.482 6.346 3.76 9.564 4.319 13.044c-.858-4.083-.15-7.866 2.338-10.353 4.906-4.906 14.849-2.917 22.208 4.443z"/>
<path fill="#FFAC33" d="M19.403 34c-.252 0-.503-.077-.719-.231l-5.076-3.641-5.076 3.641c-.433.31-1.013.31-1.443-.005-.43-.312-.611-.864-.45-1.369l1.894-6.11-5.031-3.545c-.428-.315-.605-.869-.442-1.375.165-.504.634-.847 1.165-.851l6.147-.012 2.067-5.957c.168-.504.639-.844 1.17-.844.531 0 1.002.34 1.17.844l1.866 5.957 6.347.012c.532.004 1.002.347 1.165.851.164.506-.014 1.06-.442 1.375l-5.031 3.545 1.893 6.11c.162.505-.021 1.058-.449 1.369-.217.158-.471.236-.725.236z"/>
</g>
<text x="34" y="22" font-family="system-ui, sans-serif" font-weight="600" font-size="18" fill="#ffffff">EmDash</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,8 @@
<svg width="140" height="32" viewBox="0 0 140 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Dizzy emoji (Twemoji) scaled to fit -->
<g transform="translate(0, 2) scale(0.78)">
<path fill="#FDD888" d="M28.865 7.134c7.361 7.359 9.35 17.304 4.443 22.209-4.907 4.907-14.85 2.918-22.21-4.441-.25-.25-.478-.51-.716-.766l4.417-4.417c5.724 5.724 13.016 7.714 16.286 4.442 3.271-3.271 1.282-10.563-4.441-16.287l.022.021-.021-.022C20.104 1.331 11.154-.326 6.657 4.171 4.482 6.346 3.76 9.564 4.319 13.044c-.858-4.083-.15-7.866 2.338-10.353 4.906-4.906 14.849-2.917 22.208 4.443z"/>
<path fill="#FFAC33" d="M19.403 34c-.252 0-.503-.077-.719-.231l-5.076-3.641-5.076 3.641c-.433.31-1.013.31-1.443-.005-.43-.312-.611-.864-.45-1.369l1.894-6.11-5.031-3.545c-.428-.315-.605-.869-.442-1.375.165-.504.634-.847 1.165-.851l6.147-.012 2.067-5.957c.168-.504.639-.844 1.17-.844.531 0 1.002.34 1.17.844l1.866 5.957 6.347.012c.532.004 1.002.347 1.165.851.164.506-.014 1.06-.442 1.375l-5.031 3.545 1.893 6.11c.162.505-.021 1.058-.449 1.369-.217.158-.471.236-.725.236z"/>
</g>
<text x="34" y="22" font-family="system-ui, sans-serif" font-weight="600" font-size="18" fill="#1a1a2e">EmDash</text>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,264 @@
---
title: Architecture
description: How EmDash works under the hood—database-first schema, Live Collections, plugin system, and admin panel.
---
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
EmDash integrates deeply with Astro to provide a complete CMS experience. This page explains the key architectural decisions and how the pieces fit together.
## High-Level Overview
```
┌──────────────────────────────────────────────────────────────────┐
│ Your Astro Site │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ EmDash Integration │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │ │
│ │ │ Content │ │ Admin │ │ Plugins │ │ │
│ │ │ APIs │ │ Panel │ │ │ │ │
│ │ └──────────────┘ └──────────────┘ └───────────────┘ │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────┐ │ │
│ │ │ Data Layer │ │ │
│ │ │ Database (D1/SQLite) + Storage (R2/S3) │ │ │
│ │ └──────────────────────────────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────────┐ │
│ │ Astro Framework │ │
│ │ Live Collections · Middleware · Sessions │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
EmDash runs as an Astro integration. It injects routes for the admin panel and REST APIs, provides a content loader for Live Collections, and manages database migrations and storage connections.
## Database-First Schema
Unlike traditional CMSs that define schema in code, EmDash stores schema definitions in the database itself. Two system tables track your content structure:
- `_emdash_collections` — Collection metadata (slug, label, features)
- `_emdash_fields` — Field definitions for each collection
When you create a "products" collection with title and price fields via the admin UI, EmDash:
1. Inserts records into `_emdash_collections` and `_emdash_fields`
2. Runs `ALTER TABLE` to create `ec_products` with the appropriate columns
This design enables:
- **Runtime schema modification** — Create and edit content types without code changes or rebuilds
- **Non-developer-friendly setup** — Content editors can design their data model through the UI
- **Real SQL columns** — Proper indexing, foreign keys, and query optimization
<Aside type="tip">
Run `npx emdash types` to generate TypeScript types from the live schema. This gives you type
safety for dynamically-defined collections.
</Aside>
## Per-Collection Tables
Each collection gets its own SQLite table with an `ec_` prefix:
```sql
-- Created when "posts" collection is added
CREATE TABLE ec_posts (
-- System columns (always present)
id TEXT PRIMARY KEY,
slug TEXT UNIQUE,
status TEXT DEFAULT 'draft', -- draft, published, archived
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** — `beforeCreate`, `afterCreate`, `beforeUpdate`, `afterUpdate`, `beforeDelete`
- **Media hooks** — `beforeMediaUpload`, `afterMediaUpload`
- **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. **Trusted** — 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 "@emdashcms/plugin-seo";
emdash({
plugins: [seoPlugin({ maxTitleLength: 60 })],
});
```
## Request Flow
A typical content request follows this path:
<Steps>
1. **Astro receives request** — Your page component runs 2. **Query content** —
`getEmDashCollection()` calls Astro's `getLiveCollection()` 3. **Loader executes** —
`emdashLoader` queries the appropriate `ec_*` table via Kysely 4. **Data returned** — Entries
are mapped to Astro's entry format with `id`, `slug`, and `data` 5. **Page renders** — Your
component receives the content and renders HTML
</Steps>
For admin requests:
<Steps>
1. **Middleware authenticates** — Validates session token 2. **API route handles request** — CRUD
operations via repositories 3. **Hooks fire** — `beforeCreate`, `afterUpdate`, etc. 4. **Database
updates** — Kysely executes SQL 5. **Response returned** — JSON response to admin SPA
</Steps>
## Virtual Modules
EmDash generates virtual modules at build time to configure the runtime:
| Module | Purpose |
| -------------------------------- | ----------------------------------- |
| `virtual:emdash/config` | Database and storage configuration |
| `virtual:emdash/dialect` | Database dialect factory |
| `virtual:emdash/plugin-admins` | Static imports for plugin admin UIs |
This approach ensures bundlers can properly resolve and tree-shake plugin code.
## Next Steps
<CardGrid>
<Card title="Collections" icon="document">
Learn about [content collections and field types](/concepts/collections/).
</Card>
<Card title="Content Model" icon="open-book">
Understand the [database-first content model](/concepts/content-model/).
</Card>
<Card title="Admin Panel" icon="setting">
Explore the [admin panel architecture](/concepts/admin-panel/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,381 @@
---
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 |
<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`,
`created_at`, `updated_at`, `published_at`, `deleted_at`, `version`.
</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/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,333 @@
---
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<ContentEntry<Post>[]>;
export function getEmDashEntry(
type: "products",
id: string,
): Promise<ContentEntry<Product> | null>;
}
```
<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](/guides/seeding/).
</Card>
</CardGrid>

View File

@@ -0,0 +1,459 @@
---
title: Contributing to EmDash
description: Architecture overview, local development setup, and contribution guidelines.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
This guide covers how to set up a local development environment, understand the codebase architecture, and contribute to EmDash.
## Repository Structure
EmDash is a **pnpm monorepo** with multiple packages:
```
emdash/
├── packages/
│ ├── core/ # emdash — Astro integration, APIs, admin (main package)
│ ├── auth/ # @emdashcms/auth — Authentication (passkeys, OAuth, magic links)
│ ├── cloudflare/ # @emdashcms/cloudflare — Cloudflare adapter + sandbox runner
│ ├── admin/ # @emdashcms/admin — Admin React SPA
│ ├── create-emdash/ # create-emdash — project scaffolder
│ ├── gutenberg-to-portable-text/ # WordPress block → Portable Text converter
│ └── plugins/ # First-party plugins (each subdirectory is its own package)
├── demos/
│ ├── simple/ # emdash-demo — primary dev/test demo (Node.js)
│ ├── cloudflare/ # Cloudflare Workers demo
│ └── ... # plugins-demo, showcase, wordpress-import
└── docs/ # Documentation site (Starlight)
```
The main package is `packages/core`. It contains:
```
packages/core/src/
├── astro/
│ ├── integration/ # Astro integration entry point + virtual module generation
│ ├── middleware/ # Auth, setup check, request context (ALS)
│ └── routes/
│ ├── api/ # REST API route handlers
│ └── admin-shell.astro # Admin SPA shell
├── database/
│ ├── migrations/ # Numbered migration files (001_initial.ts, ...)
│ │ └── runner.ts # StaticMigrationProvider — register migrations here
│ ├── repositories/ # Data access layer (content, media, settings, ...)
│ └── types.ts # Kysely Database type
├── plugins/
│ ├── types.ts # Plugin API types
│ ├── define-plugin.ts # definePlugin()
│ ├── context.ts # PluginContext factory
│ ├── hooks.ts # HookPipeline
│ ├── manager.ts # PluginManager (trusted plugins)
│ └── sandbox/ # Sandbox interface + no-op runner
├── schema/
│ └── registry.ts # SchemaRegistry — manages ec_* tables
├── media/ # Media providers (local, types)
├── auth/ # Challenge store, OAuth state store
├── query.ts # getEmDashCollection, getEmDashEntry
├── loader.ts # Astro LiveLoader implementation
└── emdash-runtime.ts # EmDashRuntime — central orchestrator
```
## Prerequisites
- **Node.js** 20 or higher
- **pnpm** 9 or higher
- **Git**
```bash
# Install pnpm if you don't have it
npm install -g pnpm
```
## Local Setup
<Steps>
1. **Clone the repository**
```bash
git clone <repository-url>
cd emdash
```
2. **Install dependencies**
```bash
pnpm install
```
3. **Build packages** (required before running the demo)
```bash
pnpm build
```
4. **Seed the demo database** (`demos/simple/`)
```bash
pnpm --filter emdash-demo seed
```
5. **Start the development server**
```bash
pnpm --filter emdash-demo dev
```
6. **Open the admin**
Visit [http://localhost:4321/_emdash/admin](http://localhost:4321/_emdash/admin)
In development mode, use the dev bypass endpoint to skip passkey authentication:
```
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
```
</Steps>
## Development Workflow
### Watch Mode
For package development, use watch mode alongside the demo:
```bash
# Terminal 1: Watch packages/core for changes
pnpm --filter emdash dev
# Terminal 2: Run the demo (demos/simple/)
pnpm --filter emdash-demo dev
```
### Running Tests
<Tabs>
<TabItem label="All tests">
```bash
pnpm test
```
</TabItem>
<TabItem label="Core package only">
```bash
pnpm --filter emdash test
```
</TabItem>
<TabItem label="Watch mode">
```bash
pnpm --filter emdash test --watch
```
</TabItem>
<TabItem label="E2E tests">
```bash
pnpm --filter emdash-demo test:e2e
```
</TabItem>
</Tabs>
### Type Checking and Linting
```bash
# Type check TypeScript packages
pnpm typecheck
# Type check Astro demos
pnpm typecheck:demos
# Fast lint (< 1s) — run after every edit
pnpm lint:quick
# Full lint with type-aware rules (~10s) — run before commits
pnpm lint:json
```
<Aside type="caution">
Type checking must pass after every round of edits. Run `pnpm typecheck` before committing.
</Aside>
### Formatting
```bash
pnpm format
```
EmDash uses **oxfmt** (Oxc formatter). The config is in `.prettierrc` (oxfmt uses the same config file format). Tabs, not spaces.
## Architecture Overview
### Core Concepts
**D1 is the source of truth.** Schema lives in two system tables:
- `_emdash_collections` — collection metadata
- `_emdash_fields` — field definitions
When you create a collection, EmDash runs `ALTER TABLE` to create a real `ec_*` table with typed columns. There's no EAV (Entity-Attribute-Value) approach.
**Middleware chain** (in order for every request):
1. **Runtime init** — creates database connection, initializes `EmDashRuntime`
2. **Setup check** — redirects to setup wizard if not configured
3. **Auth** — validates session, populates `locals.user`
4. **Request context** — sets up AsyncLocalStorage for preview/edit mode
**Handler layer:** business logic lives in `api/handlers/*.ts`. Route files are thin wrappers that parse input, call handlers, and format responses. Handlers return `ApiResponse<T> = { success: boolean; data?: T; error?: { code, message } }`.
### Key Files
| File | Purpose |
|------|---------|
| `src/astro/integration/index.ts` | Astro integration entry point; generates virtual modules |
| `src/emdash-runtime.ts` | Central runtime; orchestrates DB, plugins, storage |
| `src/schema/registry.ts` | Manages `ec_*` table creation/modification |
| `src/database/migrations/runner.ts` | StaticMigrationProvider; register new migrations here |
| `src/plugins/manager.ts` | Loads and orchestrates trusted plugins |
### Database Patterns
EmDash uses **Kysely** for all queries. Key rules:
```ts
// CORRECT: parameterized values
const post = await db
.selectFrom("ec_posts")
.selectAll()
.where("slug", "=", slug) // parameterized
.executeTakeFirst();
// CORRECT: validated identifier in raw SQL
validateIdentifier(tableName);
const result = await sql.raw(`SELECT * FROM ${tableName}`).execute(db);
// WRONG: never interpolate unvalidated values into SQL
const result = await sql.raw(`SELECT * FROM ${userInput}`).execute(db);
```
Never use `sql.raw()` with string interpolation for values. Use `sql.ref()` for identifiers, and the Kysely fluent API for everything else.
### Adding a Migration
<Steps>
1. Create `packages/core/src/database/migrations/NNN_description.ts`:
```ts
import type { Kysely } from "kysely";
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.createTable("my_table")
.addColumn("id", "text", (col) => col.primaryKey())
.addColumn("name", "text", (col) => col.notNull())
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable("my_table").execute();
}
```
2. Register it in `packages/core/src/database/migrations/runner.ts`:
```ts
import * as m018 from "./018_my_migration.js";
// Add to getMigrations() return value:
"018_my_migration": m018,
```
</Steps>
<Aside>
Migrations are statically imported (not auto-discovered) for Workers bundler compatibility. Always register new migrations in `runner.ts`.
</Aside>
### Adding an API Route
Route files live in `packages/core/src/astro/routes/api/`. Follow these conventions:
```ts
// packages/core/src/astro/routes/api/my-resource.ts
import type { APIRoute } from "astro";
import type { User } from "@emdashcms/auth";
import { apiError, handleError } from "#api/error.js";
import { requirePerm } from "#api/authorize.js";
import { parseBody } from "#api/parse.js";
import { z } from "zod";
export const prerender = false;
const createInput = z.object({
name: z.string().min(1),
});
export const POST: APIRoute = async ({ request, locals }) => {
const { emdash } = locals;
const user = (locals as { user?: User }).user;
if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
// requirePerm returns a 403 Response if denied, or null if authorized
const denied = requirePerm(user, "content:edit_any");
if (denied) return denied;
const body = await parseBody(request, createInput);
if (body instanceof Response) return body;
try {
// business logic here
return Response.json({ success: true });
} catch (error) {
return handleError(error, "Failed to create resource", "CREATE_ERROR");
}
};
```
Then register the route in `packages/core/src/astro/integration/routes.ts`.
### Plugin Development
Plugins are defined with `definePlugin()` and registered in the Astro config. See the [Plugin System documentation](/plugins/overview/) for the full API.
For local plugin development:
```bash
# Create a local plugin in packages/
pnpm --filter emdash dev # Watch mode
```
Link your plugin in the demo's `astro.config.mjs`:
```ts
import myPlugin from "../../packages/my-plugin/src/index.ts";
emdash({
plugins: [myPlugin()],
});
```
## Testing Patterns
Tests live in `packages/core/tests/`. The structure mirrors source:
```
tests/
├── unit/ # Pure function tests
├── integration/ # Real DB tests (in-memory SQLite)
└── e2e/ # Playwright browser tests
```
**Database tests use real SQLite**, not mocks:
```ts
import { describe, it, beforeEach, afterEach } from "vitest";
import { setupTestDatabase } from "../utils/test-db.js";
import type { Kysely } from "kysely";
import type { Database } from "../../src/database/types.js";
describe("ContentRepository", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await db.destroy();
});
it("creates a content entry", async () => {
// test with real DB
});
});
```
**E2E tests** use Playwright with the dev bypass for authentication:
```ts
await page.goto(
"http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin"
);
```
## Code Conventions
### Imports
Always use `.js` extensions for internal imports (ESM requirement):
```ts
// Correct
import { ContentRepository } from "../../database/repositories/content.js";
// Wrong
import { ContentRepository } from "../../database/repositories/content";
```
Use `import type` for type-only imports:
```ts
import type { Kysely } from "kysely";
import type { User } from "@emdashcms/auth";
```
### Error Handling
Use the shared error utilities in API routes:
```ts
// Error responses
return apiError("NOT_FOUND", "Content not found", 404);
// Catch blocks
catch (error) {
return handleError(error, "Failed to update content", "CONTENT_UPDATE_ERROR");
}
```
### Authorization
Every state-changing route must check authorization. Use `requirePerm()` from `#api/authorize.js` — it returns a `Response` (403) if denied, or `null` if authorized:
```ts
import { requirePerm } from "#api/authorize.js";
const denied = requirePerm(user, "content:edit_any");
if (denied) return denied;
```
For ownership-scoped actions, use `requireOwnerPerm()`:
```ts
import { requireOwnerPerm } from "#api/authorize.js";
const denied = requireOwnerPerm(user, item.authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;
```
## Commit and PR Process
1. Create a feature branch from `main`
2. Make changes, ensure `pnpm typecheck` and `pnpm lint:json` pass
3. Run relevant tests
4. Commit with a descriptive message
5. Open a PR targeting `main`
Commit messages should describe *why*, not just *what*:
```
# Good
fix: prevent media MIME sniffing with X-Content-Type-Options header
# Less good
fix: add header to media endpoint
```
## Getting Help
- Read `AGENTS.md` for architecture decisions and code patterns
- Check the [documentation site](https://docs.emdashcms.com) for guides and API reference

View File

@@ -0,0 +1,269 @@
---
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, { r2 } from "emdash/astro";
import { d1 } from "emdash/db";
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: {
cloudflareAccess: {
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
EmDash requires certain secrets for authentication and preview functionality.
### Required Secrets
| Variable | Purpose |
| -------- | ------- |
| `EMDASH_AUTH_SECRET` | Signs session cookies and auth tokens. Required for production. |
| `EMDASH_PREVIEW_SECRET` | Signs preview URLs for draft content. Required for preview functionality. |
Generate secure secrets:
```bash
npx emdash auth secret
```
Set secrets via Wrangler:
```bash
wrangler secret put EMDASH_AUTH_SECRET
wrangler secret put EMDASH_PREVIEW_SECRET
```
<Aside type="caution">
Never commit secrets to your repository. Always use `wrangler secret` for production deployments.
</Aside>
Access environment variables in your configuration using `import.meta.env` or the Cloudflare `env` binding.
## Preview Deployments
Deploy a preview branch:
```bash
wrangler deploy --env preview
```
Add an environment section to `wrangler.jsonc`:
```jsonc
{
"env": {
"preview": {
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-db-preview",
"database_id": "your-preview-db-id",
},
],
},
},
}
```
## Troubleshooting
### "D1 binding not found"
Verify the binding name in `wrangler.jsonc` matches your database configuration:
```js
// Must match: d1({ binding: "DB" })
"binding": "DB"
```
### "R2 binding not found"
Check that the R2 bucket is correctly bound:
```js
// Must match: r2({ binding: "MEDIA" })
"binding": "MEDIA"
```
### Migration errors
D1 migrations run via Wrangler, not at runtime. If you see schema errors:
1. Check that migrations were applied: `wrangler d1 migrations list emdash-db`
2. Re-apply if needed: `wrangler d1 migrations apply emdash-db`

View File

@@ -0,0 +1,319 @@
---
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/db";
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` | `"__ec_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 "@emdashcms/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 { d1, sqlite, libsql, postgres } from "emdash/db";
const database = import.meta.env.PROD ? d1({ binding: "DB" }) : sqlite({ url: "file:./data.db" });
export default defineConfig({
integrations: [emdash({ database })],
});
```
Or switch based on environment variables:
```js
const database = process.env.DATABASE_URL
? postgres({ connectionString: process.env.DATABASE_URL })
: sqlite({ url: "file:./data.db" });
```

View File

@@ -0,0 +1,169 @@
---
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 18+ hosting platform. This guide covers deployment to common providers using SQLite and local or S3-compatible storage.
## Prerequisites
- Node.js 18.17.1 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
Create a `Dockerfile`:
```dockerfile title="Dockerfile"
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["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
```
## Environment Variables
### Required for Production
| Variable | Description |
| -------- | ----------- |
| `EMDASH_AUTH_SECRET` | Signs session cookies and auth tokens. Generate with `npx emdash auth secret`. |
| `EMDASH_PREVIEW_SECRET` | Signs preview URLs for draft content. Generate with `npx emdash auth secret`. |
### Database and Storage
| Variable | Description | Example |
| ---------------------- | ----------------------- | -------------------------- |
| `DATABASE_PATH` | Path to SQLite database | `/data/emdash.db` |
| `HOST` | Server host | `0.0.0.0` |
| `PORT` | Server port | `4321` |
| `S3_ENDPOINT` | S3 endpoint URL | `https://xxx.r2.cloudflarestorage.com` |
| `S3_BUCKET` | S3 bucket name | `my-media-bucket` |
| `S3_ACCESS_KEY_ID` | S3 access key | `AKIA...` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | `...` |
| `S3_PUBLIC_URL` | Public URL for media | `https://cdn.example.com` |
<Aside type="caution">
Never commit secrets to your repository. Use your platform's secret management (environment variables, secret stores, etc.) for production.
</Aside>
## Persistent Storage
SQLite requires persistent disk storage. Ensure your hosting platform provides:
- A mounted volume or persistent disk
- Write access to the database directory
- Backup mechanisms for the database file
<Aside type="caution">
Ephemeral filesystems will lose your database on restart. Use libSQL with a remote database or persistent storage.
</Aside>
## Health Checks
Add a health check endpoint for load balancers:
```astro title="src/pages/health.ts"
export const GET = () => {
return new Response("OK", { status: 200 });
};
```
Configure your platform to check `/health` for liveness probes.

View File

@@ -0,0 +1,241 @@
---
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, { r2 } from "emdash/astro";
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.
```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 | Description |
| ----------------- | -------- | ------------------------------ |
| `endpoint` | `string` | S3 endpoint URL |
| `bucket` | `string` | Bucket name |
| `accessKeyId` | `string` | Access key |
| `secretAccessKey` | `string` | Secret key |
| `region` | `string` | Region (default: `"auto"`) |
| `publicUrl` | `string` | Optional CDN or public URL |
### 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, { r2, s3, local } from "emdash/astro";
const storage = import.meta.env.PROD
? r2({ binding: "MEDIA" })
: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
});
export default defineConfig({
integrations: [emdash({ storage })],
});
```
## Signed Uploads
The S3 adapter supports signed upload URLs, allowing clients to upload directly to storage without passing through your server. This improves performance for large files.
Signed uploads are automatic when using the S3 adapter. The admin interface uses them when available.
Adapters that support signed uploads:
- **S3** (including R2 via S3 API)
Adapters that do not support signed uploads:
- **R2 binding** (use S3 adapter with R2 credentials instead)
- **Local**
## Storage Interface
All storage adapters implement the same interface:
```typescript
interface Storage {
upload(options: {
key: string;
body: Buffer | Uint8Array | ReadableStream;
contentType: string;
}): Promise<UploadResult>;
download(key: string): Promise<DownloadResult>;
delete(key: string): Promise<void>;
exists(key: string): Promise<boolean>;
list(options?: ListOptions): Promise<ListResult>;
getSignedUploadUrl(options: SignedUploadOptions): Promise<SignedUploadUrl>;
getPublicUrl(key: string): string;
}
```
This consistency allows switching storage backends without changing application code.

View File

@@ -0,0 +1,258 @@
---
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** 18.17.1 or higher
- **npm**, **pnpm**, or **yarn**
- A code editor (VS Code recommended)
## Create a New Project
<Tabs>
<TabItem label="npm">
```bash
npm create astro@latest -- --template emdash
```
</TabItem>
<TabItem label="pnpm">
```bash
pnpm create astro@latest --template emdash
```
</TabItem>
<TabItem label="yarn">
```bash
yarn create astro --template 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 deployments, you'll need to set:
```bash
# Required for authentication
EMDASH_AUTH_SECRET=your-secret-here
# Optional: for content preview
EMDASH_PREVIEW_SECRET=your-preview-secret
```
Generate a secure auth secret with:
```bash
npx emdash auth secret
```
## Query Content in Pages
Use EmDash's query functions in your Astro pages. These follow Astro's live collections pattern, returning `{ entries, error }` for collections and `{ entry, error }` for single entries:
```astro title="src/pages/index.astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
const { entries: posts } = await getEmDashCollection("posts");
---
<Base title="Home">
<h1>Latest Posts</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/posts/${post.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
For single entries:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
---
<h1>{post.data.title}</h1>
```
## Generate TypeScript Types
For full type safety, generate types from your database schema:
```bash
npx emdash types
```
This creates `.emdash/types.ts` with interfaces for all your collections. Your editor will now autocomplete field names and catch type errors.
## Next Steps
You now have a working EmDash site! Here's where to go next:
- **[Core Concepts](/concepts/architecture/)** — Understand how EmDash works under the hood
- **[Working with Content](/guides/working-with-content/)** — Learn to query and render content
- **[Media Library](/guides/media-library/)** — Upload and manage images and files
- **[Create a Blog](/guides/create-a-blog/)** — Build a complete blog with categories and tags
- **[Deploy to Cloudflare](/deployment/cloudflare/)** — Take your site to production

View File

@@ -0,0 +1,140 @@
---
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.
## 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" or "Schedule the announcement for June 1st at 9am"
- **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"
- **Update metadata** -- "Set the alt text on the hero image to 'Mountain sunset'"
- **Remove files** -- "Delete the old banner image"
### 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'"
### Menus
- **View menus** -- "Show me the main navigation menu" or "What's in the footer menu?"
### 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 |
| **Editor** | All content, media, taxonomies, and menus. Can view schema. |
| **Author** | Own content and media |
| **Contributor** | Own content (no publishing) and media |
If you try something you don't have access to, the AI will let you know.
## Tips
- **Be specific about collections.** Say "create a blog post" rather than "create a post" if you have multiple collections.
- **Ask for the schema first.** If you're unsure what fields a collection has, ask "What fields does the posts collection have?" before creating or editing content.
- **Review before publishing.** Ask the AI to create content as a draft, review it in the admin panel, then ask the AI to publish it -- or publish it yourself.
- **Use compare for review.** Before publishing, ask "Compare the live and draft versions of this post" to see exactly what will change.
- **Rich text fields use Portable Text.** The AI can write content for rich text fields, but complex formatting is best done in the admin editor.
## For Developers
The MCP server endpoint, authentication methods, OAuth discovery, tool parameters, and error handling are documented in the [MCP Server Reference](/reference/mcp-server).

View File

@@ -0,0 +1,349 @@
---
title: Authentication
description: Passkey-first authentication for your EmDash site.
---
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.
For Cloudflare deployments, you can optionally use [Cloudflare Access](#cloudflare-access) as an alternative authentication provider.
## 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>
## OAuth Login
EmDash supports OAuth login with GitHub and Google when configured. Users can link their accounts after initial passkey setup.
See the [Configuration guide](/reference/configuration#oauth) for setup instructions.
## User Roles
EmDash uses role-based access control with five levels:
| Role | Level | Description |
| ----------- | ----- | ------------------------------- |
| Subscriber | 10 | View-only access |
| Contributor | 20 | Create content (needs approval) |
| 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.
## 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 } from "emdash/db";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
emdash({
database: d1({ binding: "DB" }),
auth: {
cloudflareAccess: {
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 |
### Role Mapping
Map your IdP groups to EmDash roles:
```js title="astro.config.mjs"
emdash({
auth: {
cloudflareAccess: {
teamDomain: "myteam.cloudflareaccess.com",
audience: "abc123...",
roleMapping: {
Admins: 50, // Admin
"Content Editors": 40, // Editor
Writers: 30, // Author
},
defaultRole: 20, // Contributor for users not in any group
},
},
});
```
The first matching group wins if a user belongs to multiple groups. The **first user** to access the site always becomes Admin, regardless of groups.
### Role Sync Behavior
By default (`syncRoles: false`), a user's role is set when they first log in and doesn't change afterward. This allows admins to manually adjust roles in EmDash.
Set `syncRoles: true` if you want IdP groups to be authoritative — the user's role will update on every login based on their current groups.
### How It Works
1. User visits `/_emdash/admin`
2. Cloudflare Access intercepts and redirects to your IdP
3. User authenticates (SSO, MFA, etc.)
4. Access sets a signed JWT in the request
5. EmDash validates the JWT and creates/authenticates the user
<Aside type="tip">
You can still disable users locally in EmDash. A disabled user will be rejected even if Access
allows them through.
</Aside>
### Disabled Features
When Access is enabled, these features are unavailable:
- Login page (`/_emdash/admin/login`)
- Passkey registration and management
- OAuth login
- Magic link login
- Self-signup
- User invites
User management is done entirely through your Cloudflare Access policies.
### Troubleshooting
#### "No Access JWT present"
The request reached EmDash without an Access JWT. This means:
- Access isn't configured to protect your application
- The Access policy isn't matching the admin routes
Verify your Access application covers `/_emdash/admin/*`.
#### "JWT audience mismatch"
The `audience` in your config doesn't match the JWT. Double-check the Application Audience Tag in your Access application settings.
#### "User not authorized"
The user authenticated via Access but `autoProvision` is `false` and they don't exist in EmDash. Either:
- Set `autoProvision: true`, or
- Create the user manually before they log in

View File

@@ -0,0 +1,319 @@
---
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 archived
- `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 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>
<div set:html={post.data.content} />
</article>
</Base>
```
<Aside type="tip">
For server-rendered sites, remove `getStaticPaths` and the page will render on demand. EmDash's
live content collections work in both modes.
</Aside>
## Add Categories and Tags
EmDash includes built-in category and tag taxonomies. See [Taxonomies](/guides/taxonomies/) for details on creating and managing terms.
### Filter Posts by Category
```astro title="src/pages/category/[slug].astro"
---
import { getEmDashCollection, getTerm, getTaxonomyTerms } from "emdash";
import Base from "../../layouts/Base.astro";
export async function getStaticPaths() {
const categories = await getTaxonomyTerms("category");
// Flatten hierarchical categories
const flatten = (terms) => terms.flatMap((t) => [t, ...flatten(t.children)]);
return flatten(categories).map((cat) => ({
params: { slug: cat.slug },
props: { category: cat },
}));
}
const { category } = Astro.props;
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
where: { category: category.slug },
});
---
<Base title={category.label}>
<h1>{category.label}</h1>
{category.description && <p>{category.description}</p>}
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
</Base>
```
### Display Post Categories
Show categories on individual posts:
```astro title="src/components/PostMeta.astro"
---
import { getEntryTerms } from "emdash";
interface Props {
postId: string;
}
const { postId } = Astro.props;
const categories = await getEntryTerms("posts", postId, "category");
const tags = await getEntryTerms("posts", postId, "tag");
---
<div class="post-meta">
{categories.length > 0 && (
<div class="categories">
<span>Categories:</span>
{categories.map((cat) => (
<a href={`/category/${cat.slug}`}>{cat.label}</a>
))}
</div>
)}
{tags.length > 0 && (
<div class="tags">
<span>Tags:</span>
{tags.map((tag) => (
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}
</div>
)}
</div>
```
## Add Pagination
For blogs with many posts, add pagination:
```astro title="src/pages/blog/page/[page].astro"
---
import { getEmDashCollection } from "emdash";
import Base from "../../../layouts/Base.astro";
const POSTS_PER_PAGE = 10;
export async function getStaticPaths() {
const { entries: allPosts } = await getEmDashCollection("posts", {
status: "published",
});
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages }, (_, i) => ({
params: { page: String(i + 1) },
props: { currentPage: i + 1, totalPages },
}));
}
const { currentPage, totalPages } = Astro.props;
const { entries: allPosts } = await getEmDashCollection("posts", {
status: "published",
});
const sortedPosts = allPosts.sort(
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0)
);
const start = (currentPage - 1) * POSTS_PER_PAGE;
const posts = sortedPosts.slice(start, start + POSTS_PER_PAGE);
---
<Base title={`Blog - Page ${currentPage}`}>
<h1>Blog</h1>
<ul>
{posts.map((post) => (
<li>
<a href={`/blog/${post.data.slug}`}>{post.data.title}</a>
</li>
))}
</ul>
<nav>
{currentPage > 1 && (
<a href={`/blog/page/${currentPage - 1}`}>Previous</a>
)}
<span>Page {currentPage} of {totalPages}</span>
{currentPage < totalPages && (
<a href={`/blog/page/${currentPage + 1}`}>Next</a>
)}
</nav>
</Base>
```
## Add an RSS Feed
Create an RSS feed for your blog:
```ts title="src/pages/rss.xml.ts"
import rss from "@astrojs/rss";
import { getEmDashCollection } from "emdash";
export async function GET(context) {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return rss({
title: "My Blog",
description: "A blog built with EmDash",
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.publishedAt,
description: post.data.excerpt,
link: `/blog/${post.data.slug}`,
})),
});
}
```
Install the RSS package if you haven't already:
```bash
npm install @astrojs/rss
```
## Next Steps
- [Working with Content](/guides/working-with-content/) - Learn CRUD operations in the admin
- [Media Library](/guides/media-library/) - Add images to your posts
- [Taxonomies](/guides/taxonomies/) - Create custom classification systems

View File

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

View File

@@ -0,0 +1,495 @@
---
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
Request a signed upload URL:
```bash
POST /_emdash/api/media/upload
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN
{
"filename": "hero-image.jpg",
"contentType": "image/jpeg",
"size": 245000
}
```
Response:
```json
{
"url": "https://storage.example.com/signed-upload-url...",
"method": "PUT",
"headers": {
"Content-Type": "image/jpeg"
},
"expiresAt": "2024-01-15T12:00:00Z",
"key": "media/abc123/hero-image.jpg"
}
```
Upload the file using the signed URL:
```bash
PUT https://storage.example.com/signed-upload-url...
Content-Type: image/jpeg
<file contents>
```
### 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 "@emdashcms/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 "@emdashcms/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 "@emdashcms/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 `EmDashMedia` component to render media from any provider:
```astro title="src/pages/posts/[slug].astro"
---
import { EmDashMedia } from "emdash/ui";
import { getEmDashEntry } from "emdash";
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
---
{post?.data.featured_image && (
<EmDashMedia
value={post.data.featured_image}
width={800}
height={450}
/>
)}
```
The component automatically:
- Detects the provider from the stored value
- Renders the appropriate element (`<img>`, `<video>`, etc.)
- 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 (e.g., "local", "cloudflare-images")
id: string; // Provider-specific ID
url?: string; // Direct URL (for local/external)
filename?: string; // Original filename
mimeType?: string; // MIME type
width?: number; // Image/video width
height?: number; // Image/video height
alt?: string; // Alt text
meta?: Record<string, unknown>; // Provider-specific metadata
}
```
This allows EmDash to render media correctly regardless of where it's hosted.
## Next Steps
- [Working with Content](/guides/working-with-content/) - Use media in your content
- [Create a Blog](/guides/create-a-blog/) - Add images to blog posts
- [Querying Content](/guides/querying-content/) - Display media in templates

View File

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

View File

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

View File

@@ -0,0 +1,333 @@
---
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
Add a preview secret to your environment:
```bash title=".env"
EMDASH_PREVIEW_SECRET="your-random-secret-key-here"
```
Generate a secure random string. This secret signs and verifies preview tokens.
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:
```ts
import { getPreviewUrl } from "emdash";
const previewUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: process.env.PREVIEW_SECRET!,
expiresIn: "1h",
});
// Returns: /posts/my-draft-post?_preview=eyJjaWQ...
```
With a base URL for absolute links:
```ts
const fullUrl = await getPreviewUrl({
collection: "posts",
id: "my-draft-post",
secret: process.env.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: process.env.PREVIEW_SECRET!,
pathPattern: "/blog/{id}",
});
// Returns: /blog/my-draft-post?_preview=eyJjaWQ...
```
## 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.PREVIEW_SECRET,
});
// Or with a token directly
const result = await verifyPreviewToken({
token: someTokenString,
secret: import.meta.env.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 `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}` and `{id}` placeholders (default: `"/{collection}/{id}"`)
**Returns:** `Promise<string>`
### `verifyPreviewToken(options)`
Verify a preview token.
**Options:**
- `secret` — Verification secret (string)
- `url` — URL to extract token from, OR
- `token` — Token string directly
**Returns:** `Promise<VerifyPreviewTokenResult>`
```ts
type VerifyPreviewTokenResult =
| { valid: true; payload: PreviewTokenPayload }
| { valid: false; error: "invalid" | "expired" | "malformed" | "none" };
```
### `generatePreviewToken(options)`
Generate a token without building a URL.
**Options:**
- `contentId` — Content ID in format `collection:id`
- `expiresIn` — Token validity duration (default: `"1h"`)
- `secret` — Signing secret
**Returns:** `Promise<string>`

View File

@@ -0,0 +1,413 @@
---
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, draft, or archived content:
```ts
// Only published posts
const { entries: published } = await getEmDashCollection("posts", {
status: "published",
});
// Only drafts
const { entries: drafts } = await getEmDashCollection("posts", {
status: "draft",
});
// Only archived
const { entries: archived } = await getEmDashCollection("posts", {
status: "archived",
});
```
<Aside type="tip">
Always filter by `status: "published"` for public-facing pages. Draft and archived 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";
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>
<div set:html={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>
Set `EMDASH_PREVIEW_SECRET` in your environment variables. The admin generates preview URLs
that include an HMAC-signed `_preview` token.
</Aside>
## Visual Editing
Every entry returned by query functions includes an `edit` proxy for annotating your templates. Spread it onto elements to enable inline editing for authenticated editors:
```astro
<article {...entry.edit}>
<h1 {...entry.edit.title}>{entry.data.title}</h1>
<div {...entry.edit.content}>
<PortableText value={entry.data.content} />
</div>
</article>
```
In edit mode, `{...entry.edit.title}` produces a `data-emdash-ref` attribute that the visual editing toolbar uses to enable inline editing. In production, the proxy spreads produce no output — zero runtime cost.
<Aside type="tip">
For string and text fields, inline editing uses `contenteditable`. For Portable Text fields, it
injects a TipTap editor. For image fields, it opens a media library popover.
</Aside>
## Sorting Results
`getEmDashCollection` does not guarantee sort order. Sort results in your template:
```ts
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Sort by publication date, newest first
const sorted = posts.sort(
(a, b) => (b.data.publishedAt?.getTime() ?? 0) - (a.data.publishedAt?.getTime() ?? 0),
);
```
### Common Sort Patterns
```ts
// Alphabetical by title
posts.sort((a, b) => a.data.title.localeCompare(b.data.title));
// By custom order field
posts.sort((a, b) => (a.data.order ?? 0) - (b.data.order ?? 0));
// Random order
posts.sort(() => Math.random() - 0.5);
```
## TypeScript Types
Generate TypeScript types for your collections:
```bash
npx emdash types
```
This creates `.emdash/types.ts` with interfaces for each collection. Use them for type safety:
```ts
import { getEmDashCollection, getEmDashEntry } from "emdash";
import type { Post } from "../.emdash/types";
// Type-safe collection query
const { entries: posts } = await getEmDashCollection<Post>("posts");
// posts is ContentEntry<Post>[]
// Type-safe entry query
const { entry: post } = await getEmDashEntry<Post>("posts", "my-post");
// post is ContentEntry<Post> | null
```
## Static vs. Server Rendering
EmDash content works with both static and server-rendered pages.
### Static (Prerendered)
For static pages, use `getStaticPaths` to generate routes at build time:
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashCollection, getEmDashEntry } from "emdash";
export async function getStaticPaths() {
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
return posts.map((post) => ({
params: { slug: post.data.slug },
}));
}
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug);
---
```
### Server-Rendered
For server-rendered pages, query content directly:
```astro title="src/pages/posts/[slug].astro"
---
export const prerender = false;
import { getEmDashEntry } from "emdash";
const { slug } = Astro.params;
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) {
return new Response("Server error", { status: 500 });
}
if (!post) {
return new Response(null, { status: 404 });
}
---
```
<Aside type="tip">
Server rendering shows content changes immediately without rebuilding. Use it for frequently
updated content.
</Aside>
## Performance Considerations
### Caching
EmDash uses Astro's live content collections, which handle caching automatically. For server-rendered pages, consider adding HTTP cache headers:
```astro
---
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
// Cache for 5 minutes
Astro.response.headers.set("Cache-Control", "public, max-age=300");
---
```
### Avoid Redundant Queries
Query once and pass data to components:
```astro title="src/pages/index.astro"
---
import { getEmDashCollection } from "emdash";
import PostList from "../components/PostList.astro";
import Sidebar from "../components/Sidebar.astro";
// Query once
const { entries: posts } = await getEmDashCollection("posts", {
status: "published",
});
const featured = posts.filter((p) => p.data.featured);
const recent = posts.slice(0, 5);
---
<PostList posts={featured} />
<Sidebar posts={recent} />
```
## Next Steps
- [Create a Blog](/guides/create-a-blog/) - Build a complete blog
- [Taxonomies](/guides/taxonomies/) - Filter by categories and tags
- [Working with Content](/guides/working-with-content/) - Admin CRUD operations
- [Internationalization](/guides/internationalization/) - Multilingual content and translations

View File

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

View File

@@ -0,0 +1,325 @@
---
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 pageTitle = title
? `${title} | ${settings.title}`
: settings.title;
---
<title>{pageTitle}</title>
{description && <meta name="description" content={description} />}
<!-- Open Graph -->
<meta property="og:title" content={pageTitle} />
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
{settings.url && <meta property="og:url" content={settings.url + Astro.url.pathname} />}
<!-- Twitter -->
{settings.social?.twitter && (
<meta name="twitter:site" content={settings.social.twitter} />
)}
<meta name="twitter:card" content={image ? "summary_large_image" : "summary"} />
```
## Date Formatting
Use the `dateFormat` and `timezone` settings for consistent date display:
```astro title="src/components/PostDate.astro"
---
import { getSiteSetting } from "emdash";
interface Props {
date: string;
}
const { date } = Astro.props;
const dateFormat = await getSiteSetting("dateFormat") || "MMMM d, yyyy";
const timezone = await getSiteSetting("timezone") || "UTC";
// Format using Intl.DateTimeFormat or a library like date-fns
const formatted = new Intl.DateTimeFormat("en-US", {
timeZone: timezone,
dateStyle: "long",
}).format(new Date(date));
---
<time datetime={date}>{formatted}</time>
```
<Aside>
The `dateFormat` setting uses a pattern string (e.g., "MMMM d, yyyy"). You may need a library like
`date-fns` to parse and apply these patterns.
</Aside>
## Admin API
Fetch settings programmatically:
```http
GET /_emdash/api/settings
```
Response:
```json
{
"title": "My EmDash Site",
"tagline": "A modern CMS",
"logo": {
"mediaId": "med_123",
"url": "/_emdash/api/media/file/abc123"
},
"postsPerPage": 10,
"dateFormat": "MMMM d, yyyy",
"timezone": "America/New_York",
"social": {
"twitter": "@handle",
"github": "username"
}
}
```
Update settings (partial updates supported):
```http
POST /_emdash/api/settings
Content-Type: application/json
{
"title": "New Site Title",
"tagline": "Updated tagline"
}
```
Only the provided fields are changed. Omitted fields retain their current values.
## Media References
The `logo` and `favicon` settings store media references. When you read them, EmDash resolves the `url` property automatically:
```ts
const logo = await getSiteSetting("logo");
// {
// mediaId: "med_123",
// alt: "Site logo",
// url: "/_emdash/api/media/file/abc123"
// }
```
When updating via the API, provide only the `mediaId`:
```json
{
"logo": {
"mediaId": "med_456",
"alt": "New logo"
}
}
```
## API Reference
### `getSiteSettings()`
Fetch all site settings with resolved media URLs.
**Returns:** `Promise<Partial<SiteSettings>>`
Returns a partial object. Unset values are `undefined`.
### `getSiteSetting(key)`
Fetch a single setting by key.
**Parameters:**
- `key` — The setting key (e.g., `"title"`, `"logo"`, `"social"`)
**Returns:** `Promise<SiteSettings[K] | undefined>`
Type-safe: the return type matches the key you request.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
---
title: EmDash
description: The Astro-native CMS. A modern successor to WordPress with type-safe content, plugin extensibility, and portable deployment.
template: splash
hero:
tagline: A modern, Astro-native CMS. Type-safe content, plugin extensibility, and portable deployment.
actions:
- text: Get Started
link: /getting-started/
icon: right-arrow
- text: View on GitHub
link: https://github.com/withastro/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 @emdashcms/template-blog
# Start the dev server
npm run dev
# Visit the admin at http://localhost:4321/_emdash/admin
```
## Features
- **Visual Schema Builder** — Create collections and fields from the admin panel
- **Rich Text Editor** — TipTap-powered editing with Portable Text storage
- **Media Library** — Drag-and-drop uploads with signed URL support
- **Navigation Menus** — Admin-editable menus with nested items
- **Taxonomies** — Categories, tags, and custom classification systems
- **Widget Areas** — Configurable content regions for sidebars and footers
- **WordPress Import** — Migrate content from WXR exports or REST API
- **Preview System** — Token-based preview for draft content

View File

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

View File

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

View File

@@ -0,0 +1,267 @@
---
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 |
## CLI Import (Advanced)
Developers can also import via the CLI:
```bash
# Analyze export file
npx emdash import wordpress export.xml --analyze
# Run import
npx emdash import wordpress export.xml --execute
# With media download
npx emdash import wordpress export.xml --execute --download-media
```
The CLI uses the same APIs as the dashboard and supports `--resume` for interrupted imports.
## Troubleshooting
### "XML parsing error"
The export file may be corrupted or incomplete. Re-export from WordPress.
### Media download failures
Some images may be behind authentication or have moved. The import continues, and failed URLs are logged for manual handling.
### Field type conflicts
If an existing collection has a field with an incompatible type, the import wizard shows the conflict. Either:
- Rename the EmDash field
- Change the WordPress field mapping
- Delete and recreate the collection
### Large exports
For exports over 100MB, consider:
1. Export post types separately in WordPress
2. Import each file sequentially
3. Use the CLI with `--resume` for reliability
## Next Steps
- **[Content Import](/migration/content-import/)** — Other import sources and methods
- **[Plugin Porting](/migration/plugin-porting/)** — Migrate WordPress plugin functionality
- **[Working with Content](/guides/working-with-content/)** — Query and render your imported content

View File

@@ -0,0 +1,634 @@
---
title: Porting WordPress Plugins
description: Migrate WordPress plugin functionality to EmDash plugins.
---
import { Aside, Card, CardGrid, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
WordPress plugins extend the CMS with custom functionality. EmDash provides equivalent extension points through its plugin system. This guide shows how to translate common WordPress patterns.
## Plugin Architecture Comparison
| WordPress | EmDash |
| ---------------------------------- | --------------------------------------- |
| PHP files in `wp-content/plugins/` | TypeScript modules registered in config |
| `add_action()` / `add_filter()` | Hook functions |
| Admin menu pages | Admin panel routes |
| REST API endpoints | API route handlers |
| Database via `$wpdb` | Storage via `ctx.storage` |
| Options via `wp_options` | Key-value via `ctx.kv` |
| Post meta | Collection fields |
| Shortcodes | Portable Text custom blocks |
| Gutenberg blocks | Portable Text custom blocks |
## Concept Mapping
### Actions and Filters → Hooks
WordPress uses `add_action()` and `add_filter()` for extensibility. EmDash uses typed hook functions.
<Tabs>
<TabItem label="WordPress">
```php
// WordPress action
add_action('save_post', function($post_id, $post) {
if ($post->post_type !== 'product') return;
update_post_meta($post_id, 'last_updated', time());
}, 10, 2);
// WordPress filter
add_filter('the_content', function($content) {
return $content . '<p>Read more articles</p>';
});
````
</TabItem>
<TabItem label="EmDash">
```typescript
// EmDash hook
export const hooks = {
'content:beforeSave': async (ctx, entry) => {
if (entry.collection !== 'products') return entry;
return {
...entry,
data: {
...entry.data,
lastUpdated: new Date().toISOString()
}
};
},
'content:afterRender': async (ctx, html) => {
return html + '<p>Read more articles</p>';
}
};
````
</TabItem>
</Tabs>
### Available Hooks
| Hook | Equivalent WordPress Hook | Purpose |
| ---------------------- | ---------------------------- | -------------------------- |
| `content:beforeSave` | `wp_insert_post_data` | Modify content before save |
| `content:afterSave` | `save_post` | React after content saved |
| `content:beforeDelete` | `before_delete_post` | Validate before deletion |
| `content:afterRender` | `the_content` | Transform rendered output |
| `media:beforeUpload` | `wp_handle_upload_prefilter` | Validate/transform uploads |
| `media:afterUpload` | `add_attachment` | React after upload |
| `admin:init` | `admin_init` | Admin panel initialization |
| `api:request` | `rest_pre_dispatch` | Intercept API requests |
### Database Access
WordPress uses `$wpdb` for direct database queries. EmDash provides `ctx.storage` for structured data access.
<Tabs>
<TabItem label="WordPress">
```php
global $wpdb;
// Insert
$wpdb->insert('custom_table', [
'name' => 'Example',
'value' => 42
]);
// Query
$results = $wpdb->get_results(
"SELECT \* FROM custom_table WHERE value > 10"
);
// Update
$wpdb->update('custom_table',
['value' => 50],
['name' => 'Example']
);
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Using ctx.storage (D1/SQLite)
const db = ctx.storage;
// Insert
await db.prepare(
'INSERT INTO custom_table (name, value) VALUES (?, ?)'
).bind('Example', 42).run();
// Query
const results = await db.prepare(
'SELECT * FROM custom_table WHERE value > ?'
).bind(10).all();
// Update
await db.prepare(
'UPDATE custom_table SET value = ? WHERE name = ?'
).bind(50, 'Example').run();
````
</TabItem>
</Tabs>
<Aside>
EmDash runs on Cloudflare Workers with D1 (SQLite). Use prepared statements with parameter
binding for security.
</Aside>
### Options Storage
WordPress uses `get_option()` / `update_option()`. EmDash uses `ctx.kv` for key-value storage.
<Tabs>
<TabItem label="WordPress">
```php
// Get option
$api_key = get_option('my_plugin_api_key', '');
// Set option
update_option('my_plugin_api_key', 'abc123');
// Delete option
delete_option('my_plugin_api_key');
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Get value
const apiKey = await ctx.kv.get('my_plugin_api_key') ?? '';
// Set value
await ctx.kv.put('my_plugin_api_key', 'abc123');
// Delete value
await ctx.kv.delete('my_plugin_api_key');
````
</TabItem>
</Tabs>
### Custom Post Types → Collections
WordPress registers post types with `register_post_type()`. EmDash uses collections defined in the admin UI or via API.
<Tabs>
<TabItem label="WordPress">
```php
register_post_type('product', [
'labels' => [
'name' => 'Products',
'singular_name' => 'Product'
],
'public' => true,
'supports' => ['title', 'editor', 'thumbnail'],
'has_archive' => true
]);
register_meta('post', 'price', [
'type' => 'number',
'single' => true,
'show_in_rest' => true
]);
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Create via API
await fetch('/_emdash/api/schema/collections', {
method: 'POST',
body: JSON.stringify({
slug: 'products',
label: 'Products',
labelSingular: 'Product',
fields: [
{ slug: 'title', type: 'string', required: true },
{ slug: 'content', type: 'portableText' },
{ slug: 'featuredImage', type: 'media' },
{ slug: 'price', type: 'number' }
]
})
});
````
</TabItem>
</Tabs>
Collections are typically created through the admin UI at **Content Types → New Content Type**.
### Shortcodes → Portable Text Blocks
WordPress shortcodes embed dynamic content. EmDash uses custom Portable Text blocks with React/Astro components.
<Tabs>
<TabItem label="WordPress">
```php
// Register shortcode
add_shortcode('product_card', function($atts) {
$atts = shortcode_atts([
'id' => 0,
'show_price' => true
], $atts);
$product = get_post($atts['id']);
$price = get_post_meta($atts['id'], 'price', true);
return sprintf(
'<div class="product-card">
<h3>%s</h3>
%s
</div>',
esc_html($product->post_title),
$atts['show_price'] ? '<p>$' . esc_html($price) . '</p>' : ''
);
});
// Usage in content: [product_card id="123" show_price="true"]
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Define Portable Text block schema
const productCardBlock = {
name: 'productCard',
type: 'object',
fields: [
{ name: 'productId', type: 'reference', to: 'products' },
{ name: 'showPrice', type: 'boolean', default: true }
]
};
// Render component (Astro)
---
// src/components/ProductCard.astro
import { getEntry } from 'emdash';
const { productId, showPrice = true } = Astro.props;
const product = await getEntry('products', productId);
---
<div class="product-card">
<h3>{product.data.title}</h3>
{showPrice && <p>${product.data.price}</p>}
</div>
````
```typescript
// Register with Portable Text renderer
import ProductCard from "./components/ProductCard.astro";
const components = {
types: {
productCard: ProductCard,
},
};
// Usage: <PortableText value={content} components={components} />
```
</TabItem>
</Tabs>
### Admin Pages
WordPress uses `add_menu_page()` for admin screens. EmDash plugins define admin routes.
<Tabs>
<TabItem label="WordPress">
```php
add_action('admin_menu', function() {
add_menu_page(
'My Plugin Settings',
'My Plugin',
'manage_options',
'my-plugin',
'render_settings_page',
'dashicons-admin-generic',
30
);
});
function render_settings_page() {
?>
<div class="wrap">
<h1>My Plugin Settings</h1>
<form method="post" action="options.php">
<?php settings_fields('my_plugin_options'); ?>
<input type="text" name="api_key" value="<?php echo esc_attr(get_option('api_key')); ?>">
<?php submit_button(); ?>
</form>
</div>
<?php
}
````
</TabItem>
<TabItem label="EmDash">
```typescript
// Plugin definition
export default {
name: 'my-plugin',
admin: {
// Menu entry
menu: {
label: 'My Plugin',
icon: 'settings'
},
// Admin page component
pages: [{
path: '/settings',
component: () => import('./admin/Settings')
}]
}
};
````
```tsx
// admin/Settings.tsx (React component)
import { useState, useEffect } from "react";
export default function Settings() {
const [apiKey, setApiKey] = useState("");
useEffect(() => {
fetch("/_emdash/api/plugins/my-plugin/settings")
.then((r) => r.json())
.then((data) => setApiKey(data.apiKey || ""));
}, []);
const save = async () => {
await fetch("/_emdash/api/plugins/my-plugin/settings", {
method: "POST",
body: JSON.stringify({ apiKey }),
});
};
return (
<div>
<h1>My Plugin Settings</h1>
<input value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
<button onClick={save}>Save</button>
</div>
);
}
```
</TabItem>
</Tabs>
### REST API Endpoints
WordPress uses `register_rest_route()`. EmDash plugins define API handlers.
<Tabs>
<TabItem label="WordPress">
```php
add_action('rest_api_init', function() {
register_rest_route('my-plugin/v1', '/calculate', [
'methods' => 'POST',
'callback' => function($request) {
$params = $request->get_json_params();
$result = $params['a'] + $params['b'];
return new WP_REST_Response(['result' => $result]);
},
'permission_callback' => function() {
return current_user_can('edit_posts');
}
]);
});
```
</TabItem>
<TabItem label="EmDash">
```typescript
// Plugin API routes
export default {
name: 'my-plugin',
api: {
routes: [{
method: 'POST',
path: '/calculate',
handler: async (ctx, req) => {
// Check permissions
if (!ctx.user?.can('edit:content')) {
return new Response('Forbidden', { status: 403 });
}
const { a, b } = await req.json();
return Response.json({ result: a + b });
}
}]
}
};
```
</TabItem>
</Tabs>
## Migration Workflow
<Steps>
1. **Analyze the WordPress plugin**
Identify what the plugin does:
- Custom post types and fields
- Admin pages
- Shortcodes or blocks
- Hooks used
- Database tables
- API endpoints
2. **Map concepts to EmDash**
Use the tables above to find equivalents. Note which features need different approaches.
3. **Create the EmDash plugin structure**
```
my-plugin/
├── index.ts # Plugin entry point
├── hooks.ts # Hook implementations
├── api/ # API route handlers
├── admin/ # Admin UI components
└── components/ # Portable Text components
```
4. **Implement core functionality**
Start with the data model (collections and fields), then add hooks, then admin UI.
5. **Migrate data**
If the WordPress plugin stored custom data:
- Export from WordPress (custom tables, post meta)
- Transform to EmDash format
- Import via API or direct database insert
6. **Test thoroughly**
- Verify hook behavior matches expectations
- Test admin pages render correctly
- Check API endpoints return correct data
</Steps>
## Common Plugin Patterns
### SEO Plugin
WordPress SEO plugins add meta fields and generate tags.
```typescript
export default {
name: "seo",
hooks: {
"content:beforeSave": async (ctx, entry) => {
// Auto-generate meta description from excerpt
if (!entry.data.seo?.description && entry.data.excerpt) {
return {
...entry,
data: {
...entry.data,
seo: {
...entry.data.seo,
description: entry.data.excerpt.slice(0, 160),
},
},
};
}
return entry;
},
},
// Add SEO fields to all collections
fields: {
seo: {
type: "object",
fields: [
{ slug: "title", type: "string" },
{ slug: "description", type: "text" },
{ slug: "keywords", type: "string" },
],
},
},
};
```
### Form Plugin
WordPress form plugins store submissions.
```typescript
export default {
name: "forms",
// Create submissions collection on install
install: async (ctx) => {
await ctx.schema.createCollection({
slug: "form_submissions",
label: "Form Submissions",
fields: [
{ slug: "formId", type: "string" },
{ slug: "data", type: "json" },
{ slug: "submittedAt", type: "datetime" },
],
});
},
api: {
routes: [
{
method: "POST",
path: "/submit/:formId",
handler: async (ctx, req) => {
const formId = ctx.params.formId;
const data = await req.json();
await ctx.content.create("form_submissions", {
formId,
data,
submittedAt: new Date().toISOString(),
});
return Response.json({ success: true });
},
},
],
},
};
```
### E-commerce Plugin
WordPress WooCommerce patterns translated to EmDash.
```typescript
export default {
name: "shop",
collections: [
{
slug: "products",
label: "Products",
fields: [
{ slug: "title", type: "string", required: true },
{ slug: "price", type: "number", required: true },
{ slug: "salePrice", type: "number" },
{ slug: "sku", type: "string" },
{ slug: "stock", type: "number", default: 0 },
{ slug: "gallery", type: "media", multiple: true },
],
},
],
hooks: {
"content:beforeSave": async (ctx, entry) => {
if (entry.collection !== "products") return entry;
// Generate SKU if not set
if (!entry.data.sku) {
const count = await ctx.content.count("products");
entry.data.sku = `PROD-${String(count + 1).padStart(5, "0")}`;
}
return entry;
},
},
};
```
## Security Considerations
<Aside type="caution">
EmDash plugins run in a sandboxed environment with limited capabilities. Direct file system
access and shell commands are not available.
</Aside>
### Available in Sandbox
- `ctx.storage` — Database access
- `ctx.kv` — Key-value store
- `ctx.content` — Content API
- `ctx.media` — Media API
- `fetch()` — HTTP requests
### Not Available
- File system access
- Shell commands
- Environment variables (use plugin settings)
- Global state between requests
## Next Steps
- **[WordPress Migration](/migration/from-wordpress/)** — Import your WordPress content
- **[Plugin Development](/plugins/development/)** — Full plugin development guide
- **[Hooks Reference](/reference/hooks/)** — Complete hooks API

View File

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

View File

@@ -0,0 +1,401 @@
---
title: Admin UI
description: Add admin pages and dashboard widgets to the EmDash admin panel.
---
import { Aside, Steps, Tabs, TabItem } from "@astrojs/starlight/components";
Plugins can extend the admin panel with custom pages and dashboard widgets. These are React components that render alongside core admin functionality.
## Admin Entry Point
Plugins with admin UI export components from an `admin` entry point:
```typescript title="src/admin.tsx"
import { SEOSettingsPage } from "./components/SEOSettingsPage";
import { SEODashboardWidget } from "./components/SEODashboardWidget";
// Dashboard widgets
export const widgets = {
"seo-overview": SEODashboardWidget,
};
// Admin pages
export const pages = {
"/settings": SEOSettingsPage,
};
```
Configure the entry point in `package.json`:
```json title="package.json"
{
"exports": {
".": "./dist/index.js",
"./admin": "./dist/admin.js"
}
}
```
Reference it in your plugin definition:
```typescript title="src/index.ts"
definePlugin({
id: "seo",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-seo/admin",
pages: [{ path: "/settings", label: "SEO Settings", icon: "settings" }],
widgets: [{ id: "seo-overview", title: "SEO Overview", size: "half" }],
},
});
```
## Admin Pages
Admin pages are React components that receive the plugin context via hooks.
### Page Definition
Define pages in `admin.pages`:
```typescript
admin: {
pages: [
{
path: "/settings", // URL path (relative to plugin base)
label: "Settings", // Sidebar label
icon: "settings", // Icon name (optional)
},
{
path: "/reports",
label: "Reports",
icon: "chart",
},
];
}
```
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`.
### Page Component
```typescript title="src/components/SettingsPage.tsx"
import { useState, useEffect } from "react";
import { usePluginAPI } from "@emdashcms/admin";
export function SettingsPage() {
const api = usePluginAPI();
const [settings, setSettings] = useState<Record<string, unknown>>({});
const [saving, setSaving] = useState(false);
useEffect(() => {
api.get("settings").then(setSettings);
}, []);
const handleSave = async () => {
setSaving(true);
await api.post("settings/save", settings);
setSaving(false);
};
return (
<div>
<h1>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 "@emdashcms/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 "@emdashcms/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 "@emdashcms/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", "@emdashcms/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", "@emdashcms/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:fetch"],
allowedHosts: ["api.analytics.example.com"],
storage: {
events: { indexes: ["type", "createdAt"] },
},
admin: {
entry: "@my-org/plugin-analytics/admin",
settingsSchema: {
trackingId: { type: "string", label: "Tracking ID" },
enabled: { type: "boolean", label: "Enabled", default: true },
},
pages: [
{ path: "/dashboard", label: "Dashboard", icon: "chart" },
{ path: "/settings", label: "Settings", icon: "settings" },
],
widgets: [{ id: "events-today", title: "Events Today", size: "third" }],
},
routes: {
stats: {
handler: async (ctx) => {
const today = new Date().toISOString().split("T")[0];
const count = await ctx.storage.events!.count({
createdAt: { gte: today },
});
return { today: count };
},
},
},
});
```
```typescript title="src/admin.tsx"
import { EventsWidget } from "./components/EventsWidget";
import { DashboardPage } from "./components/DashboardPage";
import { SettingsPage } from "./components/SettingsPage";
export const widgets = {
"events-today": EventsWidget,
};
export const pages = {
"/dashboard": DashboardPage,
"/settings": SettingsPage,
};
```

View File

@@ -0,0 +1,472 @@
---
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.
<Aside type="note">
API routes are a **trusted-plugin-only** feature. Sandboxed plugins cannot define custom REST
endpoints. Use Block Kit admin pages and hooks instead.
</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:fetch` capability):
```typescript
definePlugin({
id: "weather",
version: "1.0.0",
capabilities: ["network:fetch"],
allowedHosts: ["api.weather.example.com"],
routes: {
forecast: {
input: z.object({
city: z.string(),
}),
handler: async (ctx) => {
const apiKey = await ctx.kv.get<string>("settings:apiKey");
if (!apiKey) {
throw new Error("API key not configured");
}
const response = await ctx.http!.fetch(
`https://api.weather.example.com/forecast?city=${ctx.input.city}`,
{
headers: { "X-API-Key": apiKey },
},
);
if (!response.ok) {
throw new Error(`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 "@emdashcms/admin";
function SettingsPage() {
const api = usePluginAPI();
const handleSave = async (settings) => {
await api.post("settings/save", settings);
};
const loadSettings = async () => {
return api.get("settings");
};
}
```
The hook automatically prefixes the plugin ID to route URLs.
## Calling Routes Externally
Routes are accessible at their full URL:
```bash
# GET request
curl https://your-site.com/_emdash/api/plugins/forms/submissions?limit=10
# POST request
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "user@example.com"}'
```
<Aside type="caution">
Plugin routes don't have built-in authentication. For public endpoints, implement your own auth
checks. For admin-only routes, the admin session middleware provides protection.
</Aside>
## Route Context Reference
```typescript
interface RouteContext<TInput = unknown> extends PluginContext {
/** Validated input from request body or query params */
input: TInput;
/** Original request object */
request: Request;
/** Plugin metadata */
plugin: { id: string; version: string };
/** Plugin storage collections */
storage: Record<string, StorageCollection>;
/** Key-value store */
kv: KVAccess;
/** Content access (if capability declared) */
content?: ContentAccess;
/** Media access (if capability declared) */
media?: MediaAccess;
/** HTTP client (if capability declared) */
http?: HttpAccess;
/** Structured logger */
log: LogAccess;
}
```

View File

@@ -0,0 +1,144 @@
---
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>
Trusted plugins (declared in `astro.config.ts`) can still ship custom React components. Block Kit is for runtime-installed sandboxed plugins that cannot be trusted with DOM access.
</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 |
## 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 `@emdashcms/blocks` package exports builder helpers for cleaner code:
```typescript
import { blocks, elements } from "@emdashcms/blocks";
const { header, form, section, stats } = blocks;
const { textInput, toggle, select, button } = elements;
return {
blocks: [
header("SEO Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
],
};
```
## Conditional fields
Form fields can be conditionally shown based on other field values:
```json
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
```
```json
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
```
The `api_key` field only appears when `auth_enabled` is toggled on. Conditions are evaluated client-side with no round-trip.
## Try it
Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) to interactively build and test block layouts.

View File

@@ -0,0 +1,473 @@
---
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.
## Plugin Structure
Every 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: ["read:content"],
// 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 (trusted only — not available in sandboxed plugins)
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";
"@emdashcms/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 (`@emdashcms/plugin-embeds`) is a complete example of this pattern. It provides
YouTube, Vimeo, Tweet, Bluesky, Mastodon, Gist, and Link Preview block types with both admin
editing fields and site-side Astro rendering components.
</Aside>
### Package exports
Add the `./astro` export to `package.json`:
```json title="package.json"
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": { "types": "./dist/astro/index.d.ts", "import": "./dist/astro/index.js" }
}
}
```
## Next Steps
- [Hooks Reference](/plugins/hooks/) — All available hooks with signatures
- [Storage API](/plugins/storage/) — Document collections and queries
- [Settings](/plugins/settings/) — Settings schema and KV store
- [Admin UI](/plugins/admin-ui/) — Pages and widgets
- [API Routes](/plugins/api-routes/) — REST endpoints

View File

@@ -0,0 +1,510 @@
---
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. |
| `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>`
## 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 trusted 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.title,
description: event.page.description,
},
};
}
```
**Event:**
```typescript
{
page: {
url: string;
path: string;
locale: string | null;
kind: "content" | "custom";
pageType: string;
title: 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. **Trusted 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 trusted-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 |
| ---------------------- | ------------------------- | ----------------------------- |
| `plugin:install` | First plugin installation | `void` |
| `plugin:activate` | Plugin enabled | `void` |
| `plugin:deactivate` | Plugin disabled | `void` |
| `plugin:uninstall` | Plugin removed | `void` |
| `content:beforeSave` | Before content save | Modified content or `void` |
| `content:afterSave` | After content save | `void` |
| `content:beforeDelete` | Before content delete | `false` to cancel, else allow |
| `content:afterDelete` | After content delete | `void` |
| `media:beforeUpload` | Before file upload | Modified file info or `void` |
| `media:afterUpload` | After file upload | `void` |
| `page:metadata` | Page render | Contributions or `null` |
| `page:fragments` | Page render (trusted) | Contributions or `null` |

View File

@@ -0,0 +1,138 @@
---
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 |
| ---------- | ------------- |
| `read:content` | Read your content |
| `write:content` | Create, update, and delete content |
| `read:media` | Access your media library |
| `write:media` | Upload and manage media |
| `network:fetch` | 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 `write:content` 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 trusted 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 "@emdashcms/plugin-seo";
export default defineConfig({
integrations: [
emdash({
plugins: [
seoPlugin({ generateSitemap: true }),
],
}),
],
});
```
Config-based 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 config-based plugins for first-party code and plugins you maintain yourself. Use marketplace plugins for third-party extensions where sandbox isolation provides security benefits.
</Aside>
## Marketplace vs. Config: When to Use Which
| | Marketplace | Config |
| --- | --- | --- |
| **Execution** | Sandboxed V8 isolate | In-process |
| **Install method** | Admin UI | Code change + deploy |
| **Capabilities** | Enforced at runtime | Documentation only |
| **Node.js APIs** | Not available | Full access |
| **Best for** | Third-party plugins | First-party code |
| **Updates** | One-click in admin | Version bump + deploy |

View File

@@ -0,0 +1,187 @@
---
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 are **configuration transformers**, not separate applications. They run in the same process as your Astro site and interact through well-defined interfaces.
**Key principles:**
- **Declarative** — Hooks, storage, and routes are declared at definition time, not registered dynamically
- **Type-safe** — Full TypeScript support with typed context objects
- **Sandboxing-ready** — APIs designed for isolated execution on Cloudflare Workers
- **Capability-based** — Plugins declare what they need; the runtime enforces access
<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: ["read:content", "network:fetch"],
// 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 `read:content` or `write:content` |
| `ctx.media` | Read/write media files | With `read:media` or `write:media` |
| `ctx.http` | HTTP client for external requests | With `network:fetch` |
| `ctx.log` | Structured logger | Always |
| `ctx.plugin` | Plugin metadata (id, version) | Always |
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 | Grants Access To |
| --------------- | ---------------------------------------------------------------------- |
| `read:content` | `ctx.content.get()`, `ctx.content.list()` |
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
| `read:media` | `ctx.media.get()`, `ctx.media.list()` |
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` |
| `network:fetch` | `ctx.http.fetch()` |
<Aside type="tip">
`write:content` implies `read:content`. Same for media. Declare only what you need.
</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 "@emdashcms/plugin-seo";
import auditLogPlugin from "@emdashcms/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 |
| ------------- | --------------------------------------- | --------------- |
| **Trusted** | Plugins run in-process with full access | Any |
| **Sandboxed** | Plugins run in isolated V8 workers | Cloudflare only |
In trusted mode (the default), capabilities are documentation—plugins can access anything. In sandboxed mode, capabilities are enforced at the runtime level.
<Aside type="note">
Sandboxed execution requires Cloudflare Workers with Dynamic Worker Loader. Other platforms use
trusted mode only. See [Plugin Sandbox](/plugins/sandbox/) for a full comparison of the security
guarantees on each platform.
</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="Sandbox Security" icon="warning">
[Understand sandbox isolation](/plugins/sandbox/) across Cloudflare and Node.js deployments.
</Card>
</CardGrid>

View File

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

View File

@@ -0,0 +1,201 @@
---
title: Plugin Sandbox
description: How EmDash isolates untrusted plugins on Cloudflare Workers vs Node.js deployments.
---
import { Aside, Card, CardGrid, Steps } from "@astrojs/starlight/components";
EmDash supports running plugins in two execution modes: **trusted** and **sandboxed**. This page explains how each mode works, what protections they provide, and the security implications for different deployment targets.
## Execution Modes
| | Trusted | Sandboxed |
|---|---|---|
| **Runs in** | Main process | Isolated V8 isolate (Dynamic Worker Loader) |
| **Capabilities** | Advisory (not enforced) | Enforced at runtime |
| **Resource limits** | None | CPU, memory, subrequests, wall-time |
| **Network access** | Unrestricted | Blocked; only via `ctx.http` with host allowlist |
| **Data access** | Full database access | Scoped to declared capabilities via RPC bridge |
| **Available on** | All platforms | Cloudflare Workers only |
## Trusted Mode
Trusted plugins run in the same process as your Astro site. They are loaded from npm packages or local files and configured in `astro.config.mjs`:
```typescript title="astro.config.mjs"
import myPlugin from "@emdashcms/plugin-analytics";
export default defineConfig({
integrations: [
emdash({
plugins: [myPlugin()],
}),
],
});
```
In trusted mode:
- **Capabilities are documentation, not enforcement.** A plugin declaring `["read:content"]` can still access anything in the process. The `capabilities` field tells administrators what the plugin _intends_ to use.
- **No resource limits.** CPU, memory, and network usage are unbounded. A misbehaving plugin can stall the entire request.
- **Full process access.** Plugins share the Node.js/Workers runtime with your Astro site. They can import any module, access environment variables, and read/write to the filesystem (on Node.js).
<Aside type="caution" title="Security implication">
Only install trusted plugins from sources you trust — npm packages you've reviewed, or code you've written yourself. In trusted mode, a malicious plugin has the same access as your application code.
</Aside>
## Sandboxed Mode (Cloudflare Workers)
Sandboxed plugins run in isolated V8 isolates provided by Cloudflare's [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) API. Each plugin gets its own isolate with enforced limits.
To enable sandboxing, configure the sandbox runner in your Astro config:
```typescript title="astro.config.mjs"
export default defineConfig({
integrations: [
emdash({
sandboxRunner: "@emdashcms/cloudflare/sandbox",
sandboxed: [
{
manifest: seoPluginManifest,
code: seoPluginCode,
},
],
}),
],
});
```
### What the Sandbox Enforces
<Steps>
1. **Capability enforcement**
If a plugin declares `capabilities: ["read:content"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error. This is enforced by the RPC bridge — the plugin cannot bypass it because it has no direct database access.
2. **Resource limits**
Every invocation (hook or route call) runs with:
| Resource | Default | Enforced by |
|---|---|---|
| CPU time | 50ms | Worker Loader (V8 isolate) |
| Subrequests | 10 per invocation | Worker Loader (V8 isolate) |
| Wall-clock time | 30 seconds | EmDash runner (`Promise.race`) |
| Memory | ~128MB | V8 platform ceiling (not configurable per-plugin) |
Exceeding CPU or subrequest limits causes the Worker Loader to abort the isolate and throw an exception. Exceeding the wall-time limit causes EmDash to reject the invocation promise. Memory is bounded by the V8 platform ceiling but cannot be configured per-plugin.
These are the built-in defaults. Custom limits can be configured by providing a custom `SandboxRunnerFactory` that passes different values via `SandboxOptions.limits`. Per-site configuration through the EmDash integration config is not yet implemented.
3. **Network isolation**
Sandboxed plugins have `globalOutbound: null` — direct `fetch()` calls are blocked at the V8 level. Plugins must use `ctx.http.fetch()`, which proxies through the bridge. The bridge validates the target host against the plugin's `allowedHosts` list.
4. **Storage scoping**
All storage operations (KV, collections) are scoped to the plugin's ID. A plugin cannot read another plugin's data. Content and media access goes through the bridge, which checks capabilities on every call.
5. **Feature restrictions**
Some features are only available in trusted mode:
- **API routes** — Custom REST endpoints (`routes`) are not available. Sandboxed plugins interact with users through Block Kit admin pages and hooks.
- **Portable Text block types** — PT blocks require Astro components for site-side rendering (`componentsEntry`), loaded at build time from npm. Sandboxed plugins are installed at runtime and cannot ship components.
- **Custom React admin pages** — Sandboxed plugins use Block Kit for admin UI instead of shipping React components.
The `emdash plugin bundle` command warns if a plugin declares these features.
</Steps>
### Architecture
Sandboxed plugins communicate with EmDash through an RPC bridge:
```
┌─────────────────────┐ RPC ┌──────────────────────┐
│ Plugin Isolate │ ◄──────────► │ PluginBridge │
│ (Worker Loader) │ (binding) │ (WorkerEntrypoint) │
│ │ │ │
│ ctx.kv.get(k) │──────────────│► kvGet(k) │
│ ctx.content.list() │──────────────│► contentList() │
│ ctx.http.fetch(u) │──────────────│► httpFetch(u) │
└─────────────────────┘ └──────────────────────┘
┌──────────────┐
│ D1 / R2 │
└──────────────┘
```
The plugin's code runs in a V8 isolate. It receives a `ctx` object where every method is a proxy to the bridge. The bridge runs in the main EmDash worker and performs the actual database/storage operations after validating capabilities.
### Wrangler Configuration
Sandboxing requires Dynamic Worker Loader. Add to your `wrangler.jsonc`:
```jsonc
{
"worker_loaders": [{ "binding": "LOADER" }],
"r2_buckets": [{ "binding": "MEDIA", "bucket_name": "emdash-media" }],
"d1_databases": [{ "binding": "DB", "database_name": "emdash" }]
}
```
## Node.js Deployments
<Aside type="danger" title="No isolation on Node.js">
Node.js does not support plugin sandboxing. All plugins run in trusted mode 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. It returns `isAvailable() === false`.
- Attempting to load sandboxed plugins throws `SandboxNotAvailableError`.
- All plugins must be registered as trusted plugins in the `plugins` array.
- Capability declarations are purely informational — they are not enforced.
### What This Means for Security
| Threat | Cloudflare (Sandboxed) | Node.js (Trusted only) |
|---|---|---|
| Plugin reads data it shouldn't | Blocked by bridge capability checks | **Not prevented** — plugin has full DB access |
| Plugin makes unauthorized network calls | Blocked by `globalOutbound: null` + host allowlist | **Not prevented** — plugin can call `fetch()` directly |
| Plugin exhausts CPU | Isolate aborted by Worker Loader | **Not prevented** — blocks the event loop |
| Plugin exhausts memory | Isolate terminated by Worker Loader | **Not prevented** — can crash the process |
| Plugin accesses environment variables | No access (isolated V8 context) | **Not prevented** — shares `process.env` |
| Plugin accesses filesystem | No filesystem in Workers | **Not prevented** — full `fs` access |
### Recommendations for Node.js Deployments
1. **Only install plugins from trusted sources.** Review the source code of any plugin before installing. Prefer plugins published by known maintainers.
2. **Use capability declarations as a review checklist.** Even though capabilities aren't enforced, they document the plugin's intended scope. A plugin declaring `["network:fetch"]` that doesn't need network access is suspicious.
3. **Monitor resource usage.** Use process-level monitoring (e.g., `--max-old-space-size`, health checks) to catch runaway plugins.
4. **Consider Cloudflare for untrusted plugins.** If you need to run plugins from unknown sources (e.g., a marketplace), deploy on Cloudflare Workers where sandboxing is available.
## Same API, Different Guarantees
A plugin's code is identical regardless of execution mode. The `definePlugin()` API, context shape, hooks, routes, and storage all work the same way. What changes is the **enforcement**:
```typescript
// This plugin works in both trusted and sandboxed mode
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["read:content", "network:fetch"],
allowedHosts: ["api.analytics.example.com"],
hooks: {
"content:afterSave": async (event, ctx) => {
// In trusted mode: ctx.http is always present (capabilities not enforced)
// In sandboxed mode: ctx.http is present because "network:fetch" is declared
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
});
},
},
});
```
The goal is to let plugin authors develop locally in trusted mode (faster iteration, easier debugging) and deploy to sandboxed mode in production without code changes.

View File

@@ -0,0 +1,332 @@
---
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>
## Accessing Settings
Read settings in hooks and routes via `ctx.kv`:
```typescript
"content:beforeSave": async (event, ctx) => {
// Read a setting
const maxLength = await ctx.kv.get<number>("settings:maxTitleLength");
const apiKey = await ctx.kv.get<string>("settings:apiKey");
// Use defaults if not set
const limit = maxLength ?? 60;
ctx.log.info("Using max length", { limit });
return event.content;
}
```
Settings are stored with the `settings:` prefix by convention. This distinguishes user-configurable values from internal plugin state.
## KV Store API
The KV store (`ctx.kv`) is a general-purpose key-value store for plugin data:
```typescript
interface KVAccess {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}
```
### Reading Values
```typescript
// Get a single value
const enabled = await ctx.kv.get<boolean>("settings:enabled");
// Get with type
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");
```
### Writing Values
```typescript
// Set a value
await ctx.kv.set("settings:lastSync", new Date().toISOString());
// Set complex values
await ctx.kv.set("state:cache", {
data: items,
expiry: Date.now() + 3600000,
});
```
### Listing Values
```typescript
// List all settings
const settings = await ctx.kv.list("settings:");
// Returns: [{ key: "settings:enabled", value: true }, ...]
// List all plugin keys
const all = await ctx.kv.list();
```
### Deleting Values
```typescript
const deleted = await ctx.kv.delete("state:tempData");
// Returns true if key existed
```
## Key Naming Conventions
Use prefixes to organize KV data:
| Prefix | Purpose | Example |
| ----------- | ----------------------------- | ----------------- |
| `settings:` | User-configurable preferences | `settings:apiKey` |
| `state:` | Internal plugin state | `state:lastSync` |
| `cache:` | Cached data | `cache:results` |
```typescript
// Good: clear prefixes
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", timestamp);
await ctx.kv.set("cache:feed", feedData);
// Avoid: no prefix, unclear purpose
await ctx.kv.set("url", url);
```
<Aside type="tip">
The `settings:` prefix is a convention for values shown in the auto-generated settings UI. Other
prefixes are for plugin-internal use.
</Aside>
## Settings vs Storage vs KV
Choose the right storage mechanism:
| Use Case | Mechanism |
| -------------------------- | -------------------------------------------------- |
| Admin-editable preferences | `admin.settingsSchema` + `ctx.kv` with `settings:` |
| Internal plugin state | `ctx.kv` with `state:` |
| Collections of documents | `ctx.storage` |
**Settings** are for user-configurable values—things an admin might change. They get an auto-generated UI.
**KV** is for internal state like timestamps, sync cursors, or cached computations. No UI, just code.
**Storage** is for document collections with indexed queries—form submissions, audit logs, etc.
## Loading Settings in Routes
API routes can expose settings to admin UI components:
```typescript
routes: {
settings: {
handler: async (ctx) => {
const settings = await ctx.kv.list("settings:");
const result: Record<string, unknown> = {};
for (const entry of settings) {
const key = entry.key.replace("settings:", "");
result[key] = entry.value;
}
return result;
}
},
"settings/save": {
handler: async (ctx) => {
const input = ctx.input as Record<string, unknown>;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) {
await ctx.kv.set(`settings:${key}`, value);
}
}
return { success: true };
}
}
}
```
## Default Values
Settings from `settingsSchema` are not automatically persisted. They're defaults in the admin UI. Your code should handle missing values:
```typescript
"content:afterSave": async (event, ctx) => {
// Always provide a fallback
const enabled = await ctx.kv.get<boolean>("settings:enabled") ?? true;
const maxItems = await ctx.kv.get<number>("settings:maxItems") ?? 100;
if (!enabled) return;
// ...
}
```
Alternatively, persist defaults in `plugin:install`:
```typescript
hooks: {
"plugin:install": async (_event, ctx) => {
// Persist schema defaults
await ctx.kv.set("settings:enabled", true);
await ctx.kv.set("settings:maxItems", 100);
}
}
```
## Storage Implementation
KV values are stored in the `_options` table with plugin-namespaced keys:
```sql
INSERT INTO _options (name, value) VALUES
('plugin:seo:settings:siteTitle', '"My Site"'),
('plugin:seo:settings:maxTitleLength', '60');
```
The `plugin:seo:` prefix is added automatically. Your code uses `settings:siteTitle`, and EmDash stores it as `plugin:seo:settings:siteTitle`.
This ensures plugins can't accidentally overwrite each other's data.

View File

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

View File

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

View File

@@ -0,0 +1,625 @@
---
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 `ec` for brevity.
## Authentication
Commands that talk to a running EmDash instance (everything except `init`, `seed`, `export-seed`, and `auth secret`) 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 auth secret`
Generate a secure authentication secret for your deployment.
```bash
npx emdash auth secret
```
Outputs a random secret suitable for `EMDASH_AUTH_SECRET`.
## 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_AUTH_SECRET` | Secret for passkey authentication |
| `EMDASH_PREVIEW_SECRET` | Secret for preview token generation |
## Package Scripts
```json
{
"scripts": {
"dev": "emdash dev",
"init": "emdash init",
"types": "emdash types",
"seed": "emdash seed",
"export-seed": "emdash export-seed",
"db:reset": "rm -f data.db && emdash init"
}
}
```
## Exit Codes
| Code | Description |
| ---- | ---------------------------------------- |
| `0` | Success |
| `1` | Error (configuration, network, database) |

View File

@@ -0,0 +1,430 @@
---
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 @emdashcms/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)
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 "@emdashcms/plugin-seo";
plugins: [seoPlugin()];
```
### `auth`
**Optional.** Authentication configuration.
```js
auth: {
// Self-signup configuration
selfSignup: {
domains: ["example.com"],
defaultRole: 20, // Contributor
},
// OAuth providers
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
},
// 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,
},
},
}
```
#### `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
}
```
#### `auth.oauth`
Configure OAuth login providers.
```js
oauth: {
github: {
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
google: {
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
}
```
#### `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>
## 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 `@emdashcms/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` | `"__ec_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.
| Option | Type | Description |
| ----------------- | -------- | -------------------------- |
| `endpoint` | `string` | S3 endpoint URL |
| `bucket` | `string` | Bucket name |
| `accessKeyId` | `string` | Access key |
| `secretAccessKey` | `string` | Secret key |
| `region` | `string` | Region (default: `"auto"`) |
| `publicUrl` | `string` | Optional CDN URL |
```js
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",
});
```
## 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_DATABASE_URL` | Override database URL |
| `EMDASH_AUTH_SECRET` | Secret for passkey authentication |
| `EMDASH_PREVIEW_SECRET` | Secret for preview token generation |
| `EMDASH_URL` | Remote EmDash URL for schema sync |
Generate an auth secret with:
```bash
npx emdash auth secret
```
## 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": {
"@emdashcms/types": ["./.emdash/types.ts"]
}
}
}
```
Generate types with:
```bash
npx emdash types
```

View File

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

View File

@@ -0,0 +1,382 @@
---
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 and media lifecycle.
## Hook Overview
| Hook | Trigger | Can Modify |
| ---------------------- | ------------------------------ | ------------- |
| `content:beforeSave` | Before content is saved | Content data |
| `content:afterSave` | After content is saved | Nothing |
| `content:beforeDelete` | Before content is deleted | Can cancel |
| `content:afterDelete` | After content is deleted | Nothing |
| `media:beforeUpload` | Before file is uploaded | File metadata |
| `media:afterUpload` | After file is uploaded | Nothing |
| `plugin:install` | When plugin is first installed | Nothing |
| `plugin:activate` | When plugin is enabled | Nothing |
| `plugin:deactivate` | When plugin is disabled | Nothing |
| `plugin:uninstall` | When plugin is removed | Nothing |
## 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
}
```
## 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 |
## Plugin Context
All hooks receive a context object with access to plugin APIs:
```ts
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage; // Declared storage collections
kv: KVAccess; // Key-value store
content?: ContentAccess; // If read:content or write:content capability
media?: MediaAccess; // If read:media or write:media capability
http?: HttpAccess; // If network:fetch capability
log: LogAccess; // Always available
}
```
<Aside>
Context APIs are gated by plugin capabilities. Declare required capabilities in the plugin
definition.
</Aside>
## Error Handling
Errors in hooks are logged and handled based on `errorPolicy`:
- `"abort"` (default) — Stop execution, rollback transaction if applicable
- `"continue"` — Log error and continue to next hook
```ts
hooks: {
"content:beforeSave": {
errorPolicy: "continue", // Don't block save if this fails
handler: async (event, ctx) => {
try {
await ctx.http?.fetch("https://api.example.com/validate");
} catch (error) {
ctx.log.warn("Validation service unavailable", error);
}
},
},
}
```
## Execution Order
Hooks run in this order:
1. Sorted by `priority` (ascending)
2. Plugins with `dependencies` run after their dependencies
3. Within same priority, order is deterministic but unspecified
```ts
// This runs first (priority 10)
{ priority: 10, handler: ... }
// This runs second (priority 50)
{ priority: 50, handler: ... }
// This runs last (default priority 100)
{ handler: ... }
```

View File

@@ -0,0 +1,566 @@
---
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 taxonomy terms and menus. |
| `content:write` | Create, update, delete, publish, unpublish, schedule, duplicate, and restore content. Create taxonomy terms. |
| `media:read` | List and get media items. |
| `media:write` | Update and delete media metadata. |
| `schema:read` | List collections and get collection schemas. |
| `schema:write` | Create and delete collections and fields. |
| `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.
### Role Requirements
In addition to scopes, some tools require a minimum RBAC role:
| Operation | Minimum role |
| --- | --- |
| Content operations | No minimum (scopes control access) |
| Schema read | Editor (40) |
| Schema write | Admin (50) |
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 28 tools across seven domains. 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_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_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.
| 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:** `content:write`
### 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
### 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`
## 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",
"admin"
],
"bearer_methods_supported": ["header"]
}
```
### Authorization Server Metadata
```http
GET /_emdash/.well-known/oauth-authorization-server
```
```json
{
"issuer": "https://example.com/_emdash",
"authorization_endpoint": "https://example.com/_emdash/oauth/authorize",
"token_endpoint": "https://example.com/_emdash/api/oauth/token",
"scopes_supported": ["content:read", "content:write", "..."],
"response_types_supported": ["code"],
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": ["none"],
"device_authorization_endpoint": "https://example.com/_emdash/api/oauth/device/code"
}
```
When an unauthenticated request hits the MCP endpoint, the server returns:
```http
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://example.com/.well-known/oauth-protected-resource"
```
This triggers the standard MCP client discovery flow.
## Error Handling
Tool errors are returned as text content with `isError: true`:
```json
{
"content": [{ "type": "text", "text": "Collection 'nonexistent' not found" }],
"isError": true
}
```
Scope and permission errors throw MCP protocol errors:
```json
{
"jsonrpc": "2.0",
"error": {
"code": -32600,
"message": "Insufficient scope: requires content:write"
},
"id": 1
}
```
Transport-level errors (server misconfiguration, unhandled exceptions) return JSON-RPC error code `-32603` (Internal error) without leaking implementation details.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,748 @@
---
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.
- **Content pages must be server-rendered.** Content changes at runtime through the admin UI, so pages that display EmDash content cannot be prerendered. Never use `getStaticPaths()` for EmDash content routes.
- **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">
Never use `getStaticPaths()` or `export const prerender = true` for pages that display EmDash
content. Content changes at runtime through the admin UI, so these pages must be server-rendered.
</Aside>
### Homepage
```astro title="src/pages/index.astro"
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", {
where: { status: "published" },
orderBy: { publishedAt: "desc" },
limit: settings.postsPerPage ?? 10,
});
---
<Base title="Home">
<h1>Latest Posts</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</Base>
```
### Single Post
```astro title="src/pages/posts/[slug].astro"
---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
const categories = await getEntryTerms("posts", post.id, "categories");
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<div class="post-meta">
{categories.map((cat) => (
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
))}
</div>
</article>
</Base>
```
### Pages
Pages use a catch-all route at the root so their slugs map directly to top-level URLs -- a page with slug `about` renders at `/about`:
```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article>
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
```
Because this is a catch-all route, it only matches URLs that don't have a more specific route. `/posts/hello-world` still hits `posts/[slug].astro`, not this file.
### Category Archive
```astro title="src/pages/categories/[slug].astro"
---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<h1>{category.label}</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
</article>
))}
</Base>
```
## Using Images
Image fields are objects with `src` and `alt` properties, not strings. Use the `Image` component from `emdash/ui` for optimized image rendering:
```astro title="src/components/PostCard.astro"
---
import { Image } from "emdash/ui";
const { post } = Astro.props;
---
<article>
{post.data.featured_image?.src && (
<Image
image={post.data.featured_image}
alt={post.data.featured_image.alt || post.data.title}
width={800}
height={450}
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
```
<Aside type="caution">
A common mistake is treating image fields as strings. `post.data.featured_image` is an object
with `src` and `alt` -- writing `<img src={post.data.featured_image} />` renders `[object Object]`.
</Aside>
## Using Menus
Query admin-defined menus in your layouts. Never hard-code navigation links:
```astro title="src/layouts/Base.astro"
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<html>
<head>
<title>{Astro.props.title} | {settings.title}</title>
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
<nav>
{primaryMenu?.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
```
## Page Templates
Themes often need multiple page layouts -- a default layout, a full-width layout, a landing page layout. In EmDash, add a `template` select field to the pages collection and map it to layout components in your catch-all route.
Add the field to your pages collection in the seed file:
```json
{
"slug": "template",
"label": "Page Template",
"type": "string",
"widget": "select",
"options": {
"choices": [
{ "value": "default", "label": "Default" },
{ "value": "full-width", "label": "Full Width" },
{ "value": "landing", "label": "Landing Page" }
]
},
"defaultValue": "default"
}
```
Then map the value to layout components in the catch-all route:
```astro title="src/pages/[...slug].astro"
---
import { getEmDashEntry } from "emdash";
import PageDefault from "../layouts/PageDefault.astro";
import PageFullWidth from "../layouts/PageFullWidth.astro";
import PageLanding from "../layouts/PageLanding.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
const layouts = {
"default": PageDefault,
"full-width": PageFullWidth,
"landing": PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
```
Editors choose the template from a dropdown in the admin UI when editing a page.
## Adding Sections
Sections are reusable content blocks that editors can insert into any Portable Text field using the `/section` slash command. If your theme has common content patterns (hero banners, CTAs, feature grids), define them as sections in the seed file:
```json title=".emdash/seed.json"
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Your compelling tagline goes here." }
]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter Signup",
"keywords": ["newsletter", "subscribe", "email"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
},
{
"_type": "block",
"children": [
{
"_type": "span",
"text": "Get the latest updates delivered to your inbox."
}
]
}
]
}
]
}
```
Sections created from the seed file are marked with `source: "theme"`. Editors can also create their own sections (marked `source: "user"`), but theme-provided sections cannot be deleted from the admin UI.
## Adding Sample Content
Include sample content in the seed file to demonstrate your theme's design:
```json title=".emdash/seed.json"
{
"content": {
"posts": [
{
"id": "hello-world",
"slug": "hello-world",
"status": "published",
"data": {
"title": "Hello World",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
}
],
"excerpt": "Your first post on EmDash."
},
"taxonomies": {
"category": ["news"]
}
}
]
}
}
```
<Aside type="tip">
Sample content is optional during setup. Users can uncheck "Include sample content" in the Setup
Wizard if they want a clean start.
</Aside>
## Including Media
Reference images in sample content using the `$media` syntax.
For remote images:
```json
{
"data": {
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx",
"alt": "A descriptive alt text",
"filename": "hero.jpg"
}
}
}
}
```
For local images, place files in `.emdash/uploads/` and reference them:
```json
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "A descriptive alt text"
}
}
}
}
```
During seeding, media files are downloaded (or read locally) and uploaded to storage.
## Search
If your theme includes a search page, use the `LiveSearch` component for instant results:
```astro title="src/pages/search.astro"
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
/>
</Base>
```
`LiveSearch` provides debounced instant search with prefix matching, Porter stemming, and highlighted result snippets. Search must be enabled per-collection in the admin UI (Content Types > Edit > Features > Search).
## Testing Your Theme
1. Create a test project from your theme:
```bash
npm create astro@latest -- --template ./path/to/my-theme
```
2. Install dependencies and start the dev server:
```bash
cd test-site
npm install
npm run dev
```
3. Complete the Setup Wizard at `http://localhost:4321/_emdash/admin`
4. Verify collections, menus, redirects, and content were created correctly
5. Test all page templates render properly
6. Create new content through the admin to verify all fields work
## Publishing Your Theme
Publish to npm for distribution:
```bash
npm publish --access public
```
Users can then install your theme:
```bash
npm create astro@latest -- --template @your-org/emdash-theme-blog
```
For GitHub-hosted themes:
```bash
npm create astro@latest -- --template github:your-org/emdash-theme-blog
```
## Custom Portable Text Blocks
Themes can define custom Portable Text block types for specialized content. This is useful for marketing pages, landing pages, or any content that needs structured components beyond standard rich text.
### Defining Custom Blocks in Seed Content
Use a namespaced `_type` in your seed file's Portable Text content:
```json title=".emdash/seed.json"
{
"content": {
"pages": [
{
"id": "home",
"slug": "home",
"status": "published",
"data": {
"title": "Home",
"content": [
{
"_type": "marketing.hero",
"headline": "Build something amazing",
"subheadline": "The all-in-one platform for modern teams.",
"primaryCta": { "label": "Get Started", "url": "/signup" }
},
{
"_type": "marketing.features",
"_key": "features",
"headline": "Everything you need",
"features": [
{
"icon": "zap",
"title": "Lightning fast",
"description": "Built for speed."
}
]
}
]
}
}
]
}
}
```
### Creating Block Components
Create Astro components for each custom block type:
```astro title="src/components/blocks/Hero.astro"
---
interface Props {
value: {
headline: string;
subheadline?: string;
primaryCta?: { label: string; url: string };
};
}
const { value } = Astro.props;
---
<section class="hero">
<h1>{value.headline}</h1>
{value.subheadline && <p>{value.subheadline}</p>}
{value.primaryCta && (
<a href={value.primaryCta.url} class="btn">
{value.primaryCta.label}
</a>
)}
</section>
```
### Rendering Custom Blocks
Pass your custom block components to the `PortableText` component:
```astro title="src/components/MarketingBlocks.astro"
---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
interface Props {
value: unknown[];
}
const { value } = Astro.props;
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={value} components={{ types: marketingTypes }} />
```
Then use it in your pages:
```astro title="src/pages/index.astro"
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />
```
<Aside>
Custom block types don't have admin UI editors by default. Users can edit the seeded content
through the standard Portable Text editor or modify the JSON directly. For a full admin editing
experience, consider creating a plugin with custom editor components.
</Aside>
### Anchor IDs for Navigation
Add `_key` to blocks that should be linkable:
```json
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
```
Then use it as an anchor in your component:
```astro
<section id={value._key}>
<!-- content -->
</section>
```
This enables navigation links like `/#features`.
## Theme Checklist
Before publishing, verify your theme includes:
- [ ] `package.json` with `emdash` field (label, description, seed path)
- [ ] `.emdash/seed.json` with valid schema
- [ ] All collections referenced in pages exist in the seed
- [ ] Menus used in layouts are defined in the seed
- [ ] Sample content demonstrates the theme's design
- [ ] `astro.config.mjs` with database and storage configuration
- [ ] `src/live.config.ts` with EmDash loader
- [ ] No `getStaticPaths()` on content pages
- [ ] No hard-coded site title, tagline, or navigation
- [ ] Image fields accessed as objects (`image.src`), not strings
- [ ] README with setup instructions
- [ ] Custom block components for any non-standard Portable Text types
## Next Steps
- **[Seed File Format](/themes/seed-files/)** -- Complete reference for seed files
- **[Themes Overview](/themes/overview/)** -- How themes work in EmDash
- **[Porting WordPress Themes](/themes/porting-wp-themes/)** -- Convert existing WordPress themes

View File

@@ -0,0 +1,160 @@
---
title: Themes Overview
description: Understand how EmDash themes work and how they bootstrap new sites.
---
import { Aside, Card, CardGrid } from "@astrojs/starlight/components";
An EmDash theme is a complete Astro site -- pages, layouts, components, styles -- distributed via `create-astro`. It also includes a **seed file** that bootstraps the database with collections, fields, menus, redirects, and sample content on first run.
## What a Theme Provides
A theme is a working Astro project with:
- **Pages** — Astro routes for rendering content (homepage, blog posts, archives, etc.)
- **Layouts** — Shared HTML structure
- **Components** — Reusable UI elements (navigation, cards, footers)
- **Styles** — CSS or Tailwind configuration
- **A seed file** — JSON that tells the CMS what content types and fields to create
<Aside>
EmDash gives you far more control over the content model than WordPress does. Themes take
advantage of this by declaring exactly which collections and fields they need via the seed file.
Most themes should build on the standard **posts** and **pages** collections, adding fields and
taxonomies as needed rather than inventing entirely new content types.
</Aside>
## Theme Structure
```
my-theme/
├── package.json # Theme metadata + EmDash config
├── astro.config.mjs # Astro integration setup
├── src/
│ ├── live.config.ts # Live Collections configuration
│ ├── pages/ # Astro routes
│ ├── layouts/ # Layout components
│ └── components/ # UI components
└── .emdash/
├── seed.json # Schema + sample content
└── uploads/ # Optional local media files
```
## How Themes Bootstrap Sites
When you create a site from a theme, this happens:
1. `create-astro` scaffolds the project from the template
2. You run `npm install` and `npm run dev`
3. On first admin visit, the **Setup Wizard** runs automatically
4. The wizard applies the seed file, creating collections, menus, redirects, and content
5. The site is ready to use
<CardGrid>
<Card title="For Users" icon="laptop">
Pick a theme, run the wizard, start editing. No database knowledge required.
</Card>
<Card title="For Developers" icon="seti:config">
Themes are standard Astro projects. Customize freely after scaffolding.
</Card>
</CardGrid>
## Installing a Theme
Use `create-astro` with a template:
```bash
npm create astro@latest -- --template @emdashcms/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 |
| ----- | ----------- | -------- |
| `@emdashcms/template-blog` | Minimal blog with posts, pages, categories, and dark mode | Personal blogs, simple sites |
| `@emdashcms/template-portfolio` | Editorial-style portfolio with projects, serif typography (Playfair Display), and image-focused layouts | Freelancers, agencies, creatives |
| `@emdashcms/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 @emdashcms/template-blog-cloudflare
npm create astro@latest -- --template @emdashcms/template-portfolio-cloudflare
npm create astro@latest -- --template @emdashcms/template-marketing-cloudflare
```
These variants include `wrangler.jsonc` for deployment configuration.
## Customizing After Install
After the Setup Wizard completes, your site is a standard Astro project. Customize it like any Astro site:
- Edit pages in `src/pages/`
- Modify layouts in `src/layouts/`
- Add collections via the admin UI
- Install Astro integrations
- Deploy anywhere Astro runs
The seed file is only used during initial setup. Once applied, your schema lives in the database.
## Next Steps
- **[Creating Themes](/themes/creating-themes/)** — Build your own EmDash theme
- **[Seed File Format](/themes/seed-files/)** — Reference for seed file structure
- **[Getting Started](/getting-started/)** — Create your first EmDash site

View File

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

View File

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

View File

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

View File

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

5
docs/tsconfig.json Normal file
View File

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