capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)

pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 19:57:43 -05:00
1 parent af8dd75fd1
commit 4b4ef8fea6
52 files changed
+18118

No files matched your search

@@ -0,0 +1,151 @@
---
name: building-emdash-site
description: Build the site-facing parts of an EmDash CMS project on Astro, including schema and seeds, content queries, Portable Text rendering, menus, taxonomies, widgets, and deployment configuration. Use for EmDash site and theme work; use creating-plugins instead when the task is plugin authoring alone.
---
# Building an EmDash Site
EmDash is a CMS built on Astro. It stores schema in the database (not in code), serves content via live content collections, and provides a full admin UI at `/_emdash/admin`. Sites are standard Astro projects with the `emdash` integration.
## Common Gotchas
These are the things that silently break sites. Know them before you start.
1. **Image fields are objects, not strings.** `post.data.featured_image` is `{ id, src, alt }`. Writing `<img src={post.data.featured_image} />` renders `[object Object]`. Use `<Image image={post.data.featured_image} />` from `"emdash/ui"`.
2. **`entry.id` vs `entry.data.id` are different things.** `entry.id` is the slug (use in URLs). `entry.data.id` is the database ULID (use for `getEntryTerms`, `Comments`, and other API calls that need the real ID). Mixing them up causes silent empty results.
3. **Taxonomy names must match the seed exactly.** If your seed defines `"name": "category"`, you must query `getTerm("category", slug)` -- not `"categories"`. Wrong name = empty results, no error.
4. **Register cache hints when Astro's cache is enabled.** Content queries return a `cacheHint`; pass it to `Astro.cache.set(cacheHint)`. Use `getSiteSettingsWithCacheHint()`, `getMenuWithCacheHint()`, `getTaxonomyTermsWithCacheHint()`, and `getWidgetAreaWithCacheHint()` when a cached route renders those values.
5. **No `getStaticPaths` for CMS content.** EmDash content is dynamic. Pages must be server-rendered (`output: "server"` in `astro.config.mjs`).
## File Structure
Every EmDash site has these key files:
```
my-site/
├── astro.config.mjs # Astro config with emdash() integration
├── src/
│ ├── live.config.ts # EmDash loader registration (boilerplate)
│ ├── pages/ # Astro pages (all server-rendered)
│ ├── layouts/ # Layout components
│ └── components/ # Reusable components
├── seed/
│ └── seed.json # Schema + demo content
├── emdash-env.d.ts # Generated types (from `emdash types`)
└── package.json
```
## Workflow
### 1. Configure the project
Read **[references/configuration.md](references/configuration.md)** for `astro.config.mjs`, `live.config.ts`, deployment targets (Node vs Cloudflare), and type generation.
### 2. Design the schema
Read **[references/schema-and-seed.md](references/schema-and-seed.md)** for collection definitions, field types, taxonomies, menus, widget areas, sections, bylines, and the complete seed file format.
### 3. Build the pages
Read **[references/querying-and-rendering.md](references/querying-and-rendering.md)** for content queries, Portable Text rendering, the Image component, visual editing attributes, caching, and common page patterns (list, detail, taxonomy archive, RSS, search, 404).
### 4. Wire up site features
Read **[references/site-features.md](references/site-features.md)** for site settings, navigation menus, taxonomies, widget areas, search, SEO meta, comments, and page contributions.
### 5. Create the seed file
Write `seed/seed.json` with collections, fields, taxonomies, menus, widgets, and sample content.
### 6. Run and verify
```bash
pnpm dev # Start the Astro dev server
```
The runtime runs pending migrations on the first request and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates `emdash-env.d.ts` when the server starts. The admin UI is at `http://localhost:4321/_emdash/admin`.
## Quick API Cheat Sheet
```typescript
// Content (entries have .data.byline and .data.bylines eagerly loaded)
import { getEmDashCollection, getEmDashEntry } from "emdash";
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
limit: 10,
cursor,
orderBy: { published_at: "desc" },
});
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
// Site features
import {
getSiteSettings,
getSiteSettingsWithCacheHint,
getMenu,
getMenuWithCacheHint,
getTaxonomyTerms,
getTaxonomyTermsWithCacheHint,
getTerm,
getEntryTerms,
getEntriesByTerm,
getWidgetArea,
getWidgetAreaWithCacheHint,
search,
getSection,
getSeoMeta,
} from "emdash";
// Bylines (standalone queries -- usually not needed since entries have bylines attached)
import { getByline, getBylineBySlug } from "emdash";
// UI components
import {
PortableText,
Blocks,
defineBlockComponents,
Image,
WidgetArea,
EmDashHead,
EmDashBodyStart,
EmDashBodyEnd,
} from "emdash/ui";
import { Comments, CommentForm } from "emdash/ui/comments";
import LiveSearch from "emdash/ui/search";
// Page context (for plugin contributions)
import { createPublicPageContext } from "emdash/page";
```
## Plugins
EmDash supports plugins for extending the CMS with hooks, storage, settings, admin UI, API routes, and custom Portable Text block types. Consider a plugin when you need to:
- React to content lifecycle events (e.g., send a notification on publish, sync to an external service)
- Add custom admin pages or dashboard widgets
- Add reusable behavior or custom Portable Text types (e.g., embedded maps or code playgrounds)
- Provide a reusable service (e.g., analytics, forms, comments via a third-party provider)
Plugins are registered in `astro.config.mjs`:
```javascript
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
plugins: [myPlugin()],
}),
```
**To build a plugin, load the `creating-plugins` skill** (in `.agents/skills/creating-plugins/`). It covers plugin anatomy, hooks, storage, admin UI, API routes, Portable Text blocks, capabilities, and the full `definePlugin()` API.
## Reference Documents
| File | Contents |
| ---------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [references/configuration.md](references/configuration.md) | Project setup, astro.config, live.config, deployment, types |
| [references/schema-and-seed.md](references/schema-and-seed.md) | Collections, fields, taxonomies, menus, widgets, seed format |
| [references/querying-and-rendering.md](references/querying-and-rendering.md) | Content APIs, PortableText, Image, caching, page patterns |
| [references/site-features.md](references/site-features.md) | Settings, menus, widgets, search, SEO, comments, page contributions |
@@ -0,0 +1,234 @@
# Configuration
## astro.config.mjs
### Node.js (local development / self-hosted)
```javascript
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig } from "astro/config";
import emdash, { local } from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
}),
],
devToolbar: { enabled: false },
});
```
### Reverse proxy
When behind a TLS-terminating reverse proxy, `Astro.url` returns the internal address (e.g. `http://localhost:4321`) instead of the public one (`https://mysite.example.com`). This breaks passkeys, CSRF, OAuth, redirects, and more.
**Step 1:** Declare allowed public hosts via [`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) so Astro reconstructs the URL from `X-Forwarded-*` headers. In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`.
**Step 2:** If the reconstructed URL still disagrees with the browser (common with TLS termination), set **`siteUrl`**:
```javascript
emdash({
siteUrl: "https://mysite.example.com",
// ...
});
```
Or via environment variable (useful for container deployments):
```bash
EMDASH_SITE_URL=https://mysite.example.com
# or: SITE_URL=https://mysite.example.com
```
`siteUrl` replaces `passkeyPublicOrigin` (which only fixed passkeys). It applies to passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data.
With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`siteUrl`** matches the browser’s HTTPS origin -- without opening the Node port on the LAN.
### Cloudflare (D1 + R2)
```javascript
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: d1({ binding: "DB", session: "auto" }),
storage: r2({ binding: "MEDIA" }),
}),
],
devToolbar: { enabled: false },
});
```
Requires a `wrangler.jsonc` with D1 and R2 bindings:
```jsonc
{
"name": "my-site",
"main": "./src/worker.ts",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-site",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-site-media",
},
],
"triggers": {
"crons": ["* * * * *"],
},
}
```
Use the EmDash Worker entry point so scheduled publishing and maintenance run through the same deployment:
```typescript
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
```
### Plugins
Register plugins in `astro.config.mjs`:
```javascript
import auditLog from "@emdash-cms/plugin-audit-log";
emdash({
database: sqlite({ url: "file:./data.db" }),
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
plugins: [auditLog],
}),
```
## live.config.ts
Every EmDash site needs this file at `src/live.config.ts`. It's boilerplate -- the same in every project:
```typescript
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
```
This registers EmDash's live content collections with Astro. All content types are served through the single `_emdash` collection -- you query specific types using `getEmDashCollection("posts")` etc.
## emdash-env.d.ts
Auto-generated at the project root when the dev server starts. Provides TypeScript types for your collections. This is the file your `tsconfig.json` includes.
```typescript
/// <reference types="emdash/locals" />
import type { PortableTextBlock } from "emdash";
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: {
id: string;
src?: string;
alt?: string;
width?: number;
height?: number;
};
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
}
declare module "emdash" {
interface EmDashCollections {
posts: Post;
}
}
```
The dev server regenerates this file automatically when schema changes. You can also generate it manually:
## Type Generation
```bash
# From local dev server (writes emdash-env.d.ts at project root)
npx emdash types
# From remote instance
npx emdash types --url https://my-site.pages.dev
# Custom output path
npx emdash types --output src/types/cms.ts
```
The CLI also writes `.emdash/schema.json` with the raw schema for tooling.
## package.json
Key dependencies for a Node.js site:
```json
{
"dependencies": {
"astro": "^7.0.0",
"emdash": "workspace:*",
"@astrojs/node": "^11.0.0",
"@astrojs/react": "^4.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
```
For Cloudflare, replace `@astrojs/node` with `@astrojs/cloudflare` and add `@emdash-cms/cloudflare`.
## Dev Server
```bash
pnpm dev # Start the Astro dev server
npx emdash types # Refresh types from the running site
```
The runtime runs pending migrations on the first request and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates `emdash-env.d.ts` when the server starts. The admin UI is at `http://localhost:4321/_emdash/admin`. On first run, you'll go through setup to create an admin account.
@@ -0,0 +1,411 @@
# Querying and Rendering Content
## Content Queries
All query functions are imported from `"emdash"`.
### getEmDashCollection
Fetch multiple entries from a collection. Returns `{ entries, error, cacheHint, nextCursor }`.
```typescript
import { getEmDashCollection } from "emdash";
// Basic
const { entries: posts } = await getEmDashCollection("posts");
// With options
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
status: "published",
limit: 10,
orderBy: { published_at: "desc" },
where: { category: "news" },
});
```
Options:
- `status` -- filter by status (`"published"`, `"draft"`, etc.)
- `limit` -- max entries
- `cursor` -- opaque cursor for keyset pagination (pass `nextCursor` from a previous result)
- `orderBy` -- `{ field: "asc" | "desc" }` (default: `{ created_at: "desc" }`)
- `where` -- filter by field values or taxonomy terms. Supports arrays for OR: `{ category: ["news", "featured"] }`
- `locale` -- filter by locale (when i18n is configured)
### getEmDashEntry
Fetch a single entry by slug. Returns `{ entry, error, isPreview, cacheHint }`.
```typescript
import { getEmDashEntry } from "emdash";
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
```
### Entry Shape
```typescript
interface ContentEntry<T> {
id: string; // The slug (used in URLs)
data: T; // All fields, including system fields
edit: EditProxy; // Visual editing attributes (spread onto elements)
}
// data includes system fields plus your custom fields:
interface PostData {
id: string; // Database ULID (use for taxonomy lookups, etc.)
slug: string;
status: string;
title: string;
featured_image?: {
id: string;
src?: string;
alt?: string;
width?: number;
height?: number;
};
content?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
// Bylines (eagerly loaded)
byline: BylineSummary | null; // Primary author
bylines: ContentBylineCredit[]; // All credits (with roleLabel, source)
// ... your custom fields
}
```
**Important:** `entry.id` is the slug (for URLs), `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
### Caching
Query results include a `cacheHint` for Astro's Route Caching:
```astro
---
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
```
When Astro's route cache is enabled, call `Astro.cache.set(cacheHint)` so publishing invalidates cached output.
## Rendering Portable Text
### PortableText component
```astro
---
import { PortableText } from "emdash/ui";
---
<PortableText value={post.data.content} />
```
Renders standard blocks (paragraphs, headings, lists, blockquotes, code blocks, images) and inline marks (bold, italic, code, strikethrough, links).
### Custom block types
For custom Portable Text objects that belong inside a rich-text document, pass a `components` prop:
```astro
---
import { PortableText } from "emdash/ui";
import Diagram from "./blocks/Diagram.astro";
const customTypes = {
"publication.diagram": Diagram,
};
---
<PortableText value={page.data.content} components={{ type: customTypes }} />
```
Each custom component receives the block data as props.
## Rendering a blocks field
Use `Blocks` for an ordered composition stored in a `blocks` collection field. Map each generated `_type` to an Astro component at the call site.
```astro
---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageLayoutBlock } from "../../emdash-env";
import Hero from "../components/blocks/Hero.astro";
import FeatureGrid from "../components/blocks/FeatureGrid.astro";
const components = defineBlockComponents<PageLayoutBlock>({
hero: Hero,
feature_grid: FeatureGrid,
});
---
<Blocks value={page.data.layout} components={components} />
```
Each component receives `{ value, index, blockKey }`. The value retains `_version`, so a renderer can narrow old and active shapes. `Blocks` performs no database or network queries.
An unmapped type produces a visible development placeholder. In production it renders the optional `fallback` component or no output. Ship renderer support before activating a new breaking block version.
## Image Component
**Always use the EmDash Image component for CMS images.** Image fields are objects, not strings.
```astro
---
import { Image } from "emdash/ui";
---
{/* Correct -- passes the image object */}
<Image image={post.data.featured_image} />
{/* Also works with explicit props */}
{post.data.featured_image?.src && (
<img src={post.data.featured_image.src} alt={post.data.featured_image.alt || ""} />
)}
```
**Common mistake:**
```astro
{/* WRONG -- image is an object, not a string */}
<img src={post.data.featured_image} />
```
## Visual Editing Attributes
Entries include `edit` attributes for inline editing. Spread them onto the element that displays the field:
```astro
<h1 {...post.edit.title}>{post.data.title}</h1>
<p {...post.edit.excerpt}>{post.data.excerpt}</p>
<div {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
```
When an admin is logged in and views the site, these attributes enable click-to-edit functionality.
## Common Page Patterns
### List page (e.g., `/posts/index.astro`)
```astro
---
import { getEmDashCollection } from "emdash";
import { Image } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
---
<Base title="Posts">
{posts.map(post => (
<article>
{post.data.featured_image && <Image image={post.data.featured_image} />}
<a href={`/posts/${post.id}`}>{post.data.title}</a>
{post.data.excerpt && <p>{post.data.excerpt}</p>}
</article>
))}
</Base>
```
### Detail page (e.g., `/posts/[slug].astro`)
```astro
---
import { getEmDashEntry, getSeoMeta } from "emdash";
import { Image, PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
if (!slug) return Astro.redirect("/404");
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) return Astro.redirect("/404");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
});
const tags = post.data.terms?.tag ?? [];
---
<Base title={seo.title} description={seo.description}>
<article>
{post.data.featured_image && (
<div {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
)}
<h1 {...post.edit.title}>{post.data.title}</h1>
<PortableText value={post.data.content} />
{tags.length > 0 && (
<div>
{tags.map(t => <a href={`/tag/${t.slug}`}>{t.label}</a>)}
</div>
)}
</article>
</Base>
```
### Taxonomy archive (e.g., `/category/[slug].astro`)
```astro
---
import { getTaxonomyTermsWithCacheHint, getEmDashCollection } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const termsResult = await getTaxonomyTermsWithCacheHint("category", { includeCounts: false });
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) return Astro.redirect("/404");
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
Astro.cache.set(cacheHint);
}
---
<Base title={`${term.label} posts`}>
<h1>{term.label}</h1>
{posts.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
</Base>
```
### RSS feed (e.g., `/rss.xml.ts`)
```typescript
import type { APIRoute } from "astro";
import { getEmDashCollection } from "emdash";
const siteTitle = "My Site";
export const GET: APIRoute = async ({ url }) => {
const siteUrl = url.origin;
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.filter((p) => p.data.publishedAt)
.map((post) => {
const postUrl = `${siteUrl}/posts/${post.id}`;
return ` <item>
<title>${escapeXml(post.data.title)}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${post.data.publishedAt!.toUTCString()}</pubDate>
<description>${escapeXml(post.data.excerpt || "")}</description>
</item>`;
})
.join("\n");
return new Response(
`<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`,
{
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
},
);
};
function escapeXml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
```
### 404 page (`/404.astro`)
```astro
---
import Base from "../layouts/Base.astro";
---
<Base title="Not Found">
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
</Base>
```
### Empty state
When a collection has no content, show a helpful empty state:
```astro
{posts.length === 0 ? (
<section>
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new">Create a post</a>
</section>
) : (
/* ... render posts ... */
)}
```
## Pagination
`getEmDashCollection` supports cursor-based keyset pagination. Pass `cursor` from a previous result's `nextCursor` to get the next page:
```astro
---
const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
limit: 10,
cursor,
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
---
{entries.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
{nextCursor && <a href={`?cursor=${nextCursor}`}>Next page</a>}
```
`nextCursor` is `undefined` when there are no more results.
## Date Formatting
Dates come as `Date` objects. Use `toLocaleDateString` or `Intl.DateTimeFormat`:
```typescript
const formatted = post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
```
@@ -0,0 +1,529 @@
# Schema and Seed Files
The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's inlined into the build and applied automatically on the first request when the database is empty and the setup wizard hasn't been completed.
## Seed File Structure
```json
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "My Site",
"description": "A description of this site",
"author": "Author Name"
},
"settings": { ... },
"blockTypes": [ ... ],
"collections": [ ... ],
"taxonomies": [ ... ],
"menus": [ ... ],
"widgetAreas": [ ... ],
"sections": [ ... ],
"bylines": [ ... ],
"content": { ... }
}
```
## Collections
Collections define content types. Each collection becomes a database table (`ec_{slug}`).
```json
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "search", "seo"],
"commentsEnabled": true,
"fields": [ ... ]
}
```
### Collection Supports
| Support | Description |
| ------------ | ------------------------- |
| `drafts` | Draft/published workflow |
| `revisions` | Revision history |
| `preview` | Signed draft previews |
| `scheduling` | Scheduled publication |
| `search` | Full-text search indexing |
| `seo` | SEO meta fields in admin |
### Slug Rules
- Lowercase alphanumeric + underscores: `/^[a-z][a-z0-9_]*$/`
- Max 63 characters
- Cannot conflict with reserved slugs
## Field Types
| Type | Column type | Runtime shape | Notes |
| -------------- | ----------- | ------------------------------------- | ---------------------------- |
| `string` | TEXT | `string` | Single line text |
| `text` | TEXT | `string` | Multi-line text (textarea) |
| `url` | TEXT | `string` | URL input |
| `number` | REAL | `number` | Floating point |
| `integer` | INTEGER | `number` | Whole numbers |
| `boolean` | INTEGER | `boolean` | Stored as 0/1 |
| `datetime` | TEXT | `Date` | ISO 8601 string in DB |
| `select` | TEXT | `string` | One configured option |
| `multiSelect` | JSON | `string[]` | Configured option list |
| `image` | TEXT | `{ id, src?, alt?, width?, height? }` | **Object, not a string** |
| `file` | TEXT | `{ id, url?, filename?, ... }` | File reference |
| `reference` | TEXT | `string` (ID) | Reference to another entry |
| `slug` | TEXT | `string` | Slug input |
| `repeater` | JSON | `object[]` | Repeated structured rows |
| `portableText` | JSON | `PortableTextBlock[]` | Rich text as structured JSON |
| `blocks` | JSON | `{ _type, _version, _key, ... }[]` | Ordered typed composition |
| `json` | JSON | `unknown` | Arbitrary JSON data |
### Field Definition
```json
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
}
```
Fields can have:
- `slug` (required) -- field identifier
- `label` (required) -- display label in admin
- `type` (required) -- one of the types above
- `required` -- validation
- `searchable` -- include in full-text search index
### Common Field Patterns
**Blog post:**
```json
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
{ "slug": "featured_image", "label": "Featured Image", "type": "image" },
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
]
```
**Portfolio project:**
```json
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
{ "slug": "featured_image", "label": "Featured Image", "type": "image", "required": true },
{ "slug": "client", "label": "Client", "type": "string" },
{ "slug": "year", "label": "Year", "type": "string" },
{ "slug": "summary", "label": "Summary", "type": "text", "searchable": true },
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
{ "slug": "gallery", "label": "Gallery", "type": "repeater", "validation": { "subFields": [{ "slug": "image", "label": "Image", "type": "image", "required": true }] } },
{ "slug": "url", "label": "Project URL", "type": "url" }
]
```
**Page (minimal):**
```json
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true }
]
```
## Block types
Define `blockTypes` before collections that use a `blocks` field. Each type retains every numbered version and names one active version for new blocks.
```json
{
"version": "1",
"blockTypes": [
{
"slug": "hero",
"label": "Hero",
"currentVersion": 1,
"versions": [
{
"version": 1,
"fields": [
{ "slug": "heading", "label": "Heading", "type": "string", "required": true },
{ "slug": "image", "label": "Image", "type": "image" }
]
}
]
}
],
"collections": [
{
"slug": "pages",
"label": "Pages",
"fields": [
{
"slug": "layout",
"label": "Layout",
"type": "blocks",
"validation": { "allowedTypes": ["hero"], "maxItems": 20 }
}
]
}
]
}
```
Compatible changes amend the active version. A breaking change creates a new inactive version; deploy renderers for it before activation, then migrate stored blocks explicitly. Removing a type from `allowedTypes` moves it to the server-managed `retiredTypes` list.
Seeded block values include their type version and stable key:
```json
{
"_type": "hero",
"_version": 1,
"_key": "home-hero",
"heading": "Build something useful"
}
```
Adding a required blocks field or a positive `minItems` rule to a populated collection is not an additive schema change. Backfill every existing entry before enforcing the requirement.
Block fields support scalar, text, selection, Portable Text, image, file, and repeater fields. References, JSON, slugs, nested blocks, custom widgets, indexes, uniqueness, and per-subfield localization are not supported inside block definitions.
## Taxonomies
Taxonomies are tag/category systems attached to collections.
```json
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "development", "label": "Development" },
{ "slug": "design", "label": "Design" }
]
}
```
- `hierarchical: true` -- tree structure (like WordPress categories)
- `hierarchical: false` -- flat list (like WordPress tags)
- `collections` -- which collections this taxonomy applies to
- `terms` -- pre-defined terms to create
## Menus
Navigation menus, managed from the admin UI.
```json
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "About", "url": "/pages/about" },
{ "type": "custom", "label": "Posts", "url": "/posts" }
]
}
```
Menu item types:
- `custom` -- arbitrary URL
- Content references are resolved at render time
## Widget Areas
Named regions where editors can add configurable widgets.
```json
{
"name": "sidebar",
"label": "Sidebar",
"description": "Widget area displayed on single post pages",
"widgets": [
{
"type": "component",
"componentId": "core:search",
"title": "Search"
},
{
"type": "component",
"componentId": "core:categories",
"title": "Categories"
},
{
"type": "component",
"componentId": "core:tags",
"title": "Tags"
},
{
"type": "component",
"componentId": "core:recent-posts",
"title": "Recent Posts",
"settings": { "count": 5, "showDate": true }
},
{
"type": "component",
"componentId": "core:archives",
"title": "Archives",
"settings": { "type": "monthly", "limit": 6 }
},
{
"type": "content",
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Some rich text content." }]
}
]
}
]
}
```
### Widget types
| Type | Description | Key fields |
| ----------- | ------------------------- | ------------------------- |
| `content` | Rich text (Portable Text) | `content` |
| `menu` | Navigation menu | `menuName` |
| `component` | Core or custom component | `componentId`, `settings` |
### Core widget components
- `core:search` -- search form
- `core:categories` -- category list with counts
- `core:tags` -- tag cloud
- `core:recent-posts` -- latest posts list
- `core:archives` -- monthly archive links
## Sections (Reusable Blocks)
Reusable content blocks that editors can insert via `/section` slash command in the editor.
```json
{
"slug": "newsletter-signup",
"title": "Newsletter Signup",
"description": "A call-to-action block for newsletter subscriptions",
"keywords": ["newsletter", "subscribe", "email", "cta"],
"source": "theme",
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Stay in the loop" }]
},
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Get notified when new posts are published." }]
}
]
}
```
## Bylines
Named author profiles, independent of user accounts.
```json
{
"id": "byline-editorial",
"slug": "emdash-editorial",
"displayName": "EmDash Editorial"
}
```
Guest bylines:
```json
{
"id": "byline-guest",
"slug": "guest-contributor",
"displayName": "Guest Contributor",
"isGuest": true
}
```
## Settings
Site-wide settings:
```json
"settings": {
"title": "My Blog",
"tagline": "Thoughts on building for the web"
}
```
Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.
## Content
Sample content organized by collection slug:
```json
"content": {
"posts": [
{
"id": "post-1",
"slug": "hello-world",
"status": "published",
"data": {
"title": "Hello World",
"excerpt": "My first post.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop",
"alt": "Description of image",
"filename": "hello-world.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "This is the body text." }]
}
]
},
"bylines": [
{ "byline": "byline-editorial" }
],
"taxonomies": {
"category": ["development"],
"tag": ["webdev", "opinion"]
}
}
],
"pages": [
{
"id": "about",
"slug": "about",
"status": "published",
"data": {
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "About this site." }]
}
]
}
}
]
}
```
### Media references in seed content
Use `$media` for image fields -- EmDash downloads and stores the image:
```json
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop",
"alt": "Description",
"filename": "my-image.jpg"
}
}
```
For external images without downloading:
```json
"featured_image": "https://images.unsplash.com/photo-xxx?w=1200"
```
### Reference fields in seed content
Use `$ref:id` format to reference other entries:
```json
"author": "$ref:byline-editorial"
```
### Portable Text in seed content
Content fields of type `portableText` are arrays of blocks:
```json
[
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "A paragraph." }]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "A heading" }]
},
{
"_type": "block",
"style": "blockquote",
"children": [{ "_type": "span", "text": "A quote." }]
}
]
```
Inline marks (bold, italic, links):
```json
{
"_type": "block",
"style": "normal",
"children": [
{ "_type": "span", "text": "This is " },
{ "_type": "span", "text": "bold", "marks": ["strong"] },
{ "_type": "span", "text": " and " },
{ "_type": "span", "text": "italic", "marks": ["em"] }
]
}
```
Block styles: `normal`, `h1`-`h6`, `blockquote`.
### Draft content
Set `"status": "draft"` to create unpublished content:
```json
{
"id": "post-draft",
"slug": "work-in-progress",
"status": "draft",
"data": { ... }
}
```
## Applying Seeds
The seed at `.emdash/seed.json`, `package.json#emdash.seed`, or `seed/seed.json` is inlined into the build and applied on the first request when the database is empty and the setup wizard hasn't been completed. Existing data is never overwritten.
Validation runs at apply time. Common errors caught:
- Image fields with raw URLs (should use `$media`)
- Reference fields with raw IDs (should use `$ref:id`)
- PortableText not an array or missing `_type`
- Type mismatches (string vs number, etc.)
If the seed is invalid, the first request fails and the error is logged. Restart the dev server after fixing it.
## Exporting Seeds
```bash
npx emdash export-seed # Schema only
npx emdash export-seed --with-content # Schema + all content
npx emdash export-seed --with-content=posts,pages # Specific collections
```
@@ -0,0 +1,540 @@
# Site Features
## Site Settings
```typescript
import { getSiteSettings, getSiteSettingsWithCacheHint, getSiteSetting } from "emdash";
// All settings
const settings = await getSiteSettings();
settings.title; // "My Site"
settings.tagline; // "A description"
settings.logo?.url; // Resolved media URL
settings.favicon?.url;
// Single setting
const title = await getSiteSetting("title");
// Cached route: register invalidation for settings changes
const { data: cachedSettings, cacheHint } = await getSiteSettingsWithCacheHint();
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
```
Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.
Use these instead of hard-coding site name, logo, etc.
## Navigation Menus
```typescript
import { getMenu, getMenuWithCacheHint, getMenus } from "emdash";
// Fetch a named menu
const menu = await getMenu("primary");
// List all menus
const menus = await getMenus();
// Cached route
const { data: primaryMenu, cacheHint } = await getMenuWithCacheHint("primary");
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
```
### Rendering a menu
```astro
---
import { getMenu } from "emdash";
const primaryMenu = await getMenu("primary");
---
<nav>
{primaryMenu?.items.map(item => (
<a
href={item.url}
target={item.target}
rel={item.target === "_blank" ? "noopener noreferrer" : undefined}
>{item.label}</a>
))}
</nav>
```
### Nested menus (dropdowns)
```astro
{primaryMenu?.items.map(item => (
<li>
<a href={item.url}>{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>
))}
```
### MenuItem shape
```typescript
interface MenuItem {
id: string;
label: string;
url: string; // Resolved URL
target?: string; // "_blank" etc.
children: MenuItem[];
}
```
## Taxonomies
```typescript
import {
getTaxonomyTerms,
getTaxonomyTermsWithCacheHint,
getTerm,
getEntryTerms,
getEntriesByTerm,
} from "emdash";
// All terms in a taxonomy (name must match your seed's "name" field exactly)
const categories = await getTaxonomyTerms("category");
const tags = await getTaxonomyTerms("tag");
// Cached taxonomy archive or facet
const { data: cachedCategories, cacheHint } = await getTaxonomyTermsWithCacheHint("category", {
includeCounts: false,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Single term by slug
const term = await getTerm("category", "news");
// { id, name, slug, label, children, count }
// Terms for a specific entry (use data.id, not entry.id!)
const postCategories = await getEntryTerms("posts", post.data.id, "category");
const postTags = await getEntryTerms("posts", post.data.id, "tag");
// Entries with a specific term
const newsPosts = await getEntriesByTerm("posts", "category", "news");
```
**Important:** The taxonomy name argument must match exactly what your seed defines in `"name"`. The blog seed uses `"category"` and `"tag"` (singular). Using `"categories"` returns empty results with no error.
**Important:** `getEntryTerms` takes the database ULID (`post.data.id`), not the slug (`post.id`).
### Displaying post terms
```astro
---
const tags = post.data.terms?.tag ?? [];
---
{tags.map(t => (
<a href={`/tag/${t.slug}`}>{t.label}</a>
))}
```
### Filtering by taxonomy
```astro
---
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
---
```
## Widget Areas
Render a named widget area:
```astro
---
import { WidgetArea } from "emdash/ui";
---
<aside>
<WidgetArea name="sidebar" />
</aside>
```
`WidgetArea` registers its widget-area cache hint when Astro's route cache is enabled.
The `WidgetArea` component automatically renders all widgets in the area (search, categories, tags, recent posts, rich text, etc.) with appropriate HTML and CSS classes.
### Manual widget rendering
For more control, use the `getWidgetArea` function:
```astro
---
import { getWidgetArea } from "emdash";
import { PortableText } from "emdash/ui";
const sidebar = await getWidgetArea("sidebar");
---
{sidebar?.widgets.map(widget => (
<div class="widget">
{widget.title && <h3>{widget.title}</h3>}
{widget.type === "content" && widget.content && (
<PortableText value={widget.content} />
)}
</div>
))}
```
## Search
### LiveSearch component (instant search)
```astro
---
import LiveSearch from "emdash/ui/search";
---
<LiveSearch
placeholder="Search..."
collections={["posts", "pages"]}
/>
```
Customizable CSS classes:
```astro
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
expandOnFocus={{ collapsed: "180px", expanded: "280px" }}
/>
```
Theme via CSS variables:
```css
:root {
--emdash-search-bg: var(--color-bg);
--emdash-search-text: var(--color-text);
--emdash-search-muted: var(--color-muted);
--emdash-search-border: var(--color-border);
--emdash-search-hover: var(--color-surface);
--emdash-search-highlight: var(--color-text);
}
```
### Programmatic search
```typescript
import { search } from "emdash";
const results = await search("hello world", {
collections: ["posts", "pages"],
status: "published",
limit: 20,
});
// { items: SearchResult[], nextCursor? }
```
Each item has: `collection`, `id`, `title`, `slug`, `locale`, `snippet` (sanitized HTML with `<mark>` highlights), and `score`.
### Search page
```astro
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q") || "";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts..."
collections={["posts", "pages"]}
/>
</Base>
```
### Keyboard shortcut
Add Cmd+K / Ctrl+K to focus search:
```html
<script>
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
document.querySelector(".site-search-input")?.focus();
}
});
</script>
```
### Search prerequisites
Search requires per-collection enablement:
1. In admin: Edit Content Type -> check "Search" in Features
2. Mark fields as `"searchable": true` in the seed file
3. Only searchable fields of searchable collections are indexed
## SEO Meta
> [!IMPORTANT]
> On server-rendered content pages that fetch the entry with `getEmDashEntry()`
> and render `<EmDashHead>`, EmDash automatically applies the SEO panel's
> description, image, canonical URL, and noindex setting. The panel title
> supplies the social and JSON-LD title contributions. The panel data uses the
> entry query the page already runs, so it adds no additional query.
>
> `<EmDashHead>` cannot set the document `<title>`, so use `getSeoMeta()` for
> the title. Prerendered pages, pages without `<EmDashHead>`, hand-rolled query
> paths, and multi-entry collection results do not receive this overlay. Use
> `getSeoMeta()` or the raw SEO data on those paths.
Use `getSeoMeta()` when the template needs to set `<title>` or the page does not
receive the automatic overlay:
```typescript
import { getSeoMeta } from "emdash";
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
defaultOgImage: featuredImageUrl, // Optional fallback
});
```
Use in your layout's `<head>`:
```astro
<title>{seo.title}</title>
{seo.description && <meta name="description" content={seo.description} />}
{seo.canonical && <link rel="canonical" href={seo.canonical} />}
{seo.ogImage && <meta property="og:image" content={seo.ogImage} />}
{seo.robots && <meta name="robots" content={seo.robots} />}
```
### Custom title and description defaults
Pass computed fallbacks through `defaultTitle` and `defaultDescription`. Values
set in the SEO panel take precedence over these defaults:
```ts
import { getSeoMeta } from "emdash";
const seo = getSeoMeta(post, {
defaultTitle: `${post.data.title}: A practical guide`,
defaultDescription: `Read ${post.data.title} on My Blog.`,
});
```
## Comments
Built-in comments system:
```astro
---
import { Comments, CommentForm } from "emdash/ui/comments";
---
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
```
Comments are enabled per-collection in the seed: `"commentsEnabled": true`.
## Page Contributions (Plugin Head/Body Injection)
Plugins can inject content into the `<head>` and `<body>` of pages. To support this, use the page contribution components:
```astro
---
import { EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "article",
title: fullTitle,
pageTitle: post.data.title,
description,
canonical,
image,
content: { collection: "posts", id: post.data.id, slug },
});
---
<html>
<head>
<!-- your meta tags -->
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<!-- your content -->
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
```
This enables plugins (analytics, tracking pixels, structured data, etc.) to contribute to any page.
## Bylines
Bylines are author profiles, independent of user accounts. They support guest authors and multi-author attribution with role labels.
### Eagerly loaded on entries
Bylines are automatically attached to every entry by the query layer:
```astro
{/* Primary author */}
{post.data.byline && (
<span>{post.data.byline.displayName}</span>
)}
{/* All credits (includes roleLabel for co-authors, guest essays, etc.) */}
{post.data.bylines?.map(credit => (
<span>
{credit.byline.displayName}
{credit.roleLabel && <em> ({credit.roleLabel})</em>}
</span>
))}
```
- `entry.data.byline` -- primary `BylineSummary` or `null`
- `entry.data.bylines` -- array of `ContentBylineCredit` (each has `.byline`, `.roleLabel`, `.source`)
### Standalone query functions
```typescript
import { getByline, getBylineBySlug } from "emdash";
// Look up a specific byline
const byline = await getBylineBySlug("jane-doe");
```
### BylineSummary shape
```typescript
interface BylineSummary {
id: string;
slug: string;
displayName: string;
bio: string | null;
avatarMediaId: string | null;
websiteUrl: string | null;
isGuest: boolean;
}
```
### ContentBylineCredit shape
```typescript
interface ContentBylineCredit {
byline: BylineSummary;
sortOrder: number;
roleLabel: string | null; // e.g., "Guest essay", "Photographer"
source?: "explicit" | "inferred"; // "inferred" = fallback from author_id
}
```
## Dark Mode Pattern
Cookie-based theme switching (no flash on load):
```html
<!-- In <head>, before styles load -->
<script is:inline>
(function () {
var c = document.cookie;
var i = c.indexOf("theme=");
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
}
})();
</script>
```
Then use CSS variables that change based on `.dark` class:
```css
:root {
--color-bg: #ffffff;
--color-text: #1a1a1a;
}
:root.dark {
--color-bg: #0d0d0d;
--color-text: #ededed;
}
```
## Layout Pattern
A typical base layout:
```astro
---
import { getMenu, getEmDashCollection } from "emdash";
import { WidgetArea, EmDashHead, EmDashBodyStart, EmDashBodyEnd } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
interface Props {
title: string;
description?: string | null;
image?: string | null;
content?: { collection: string; id: string; slug?: string | null };
}
const { title, pageTitle, description, image, content } = Astro.props;
const menu = await getMenu("primary");
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: "website",
title,
pageTitle: pageTitle ?? title,
description,
image,
content,
});
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header>
<nav>
<a href="/">My Site</a>
<LiveSearch placeholder="Search..." collections={["posts", "pages"]} />
{menu?.items.map(item => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
<footer>
<WidgetArea name="footer" />
</footer>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
```
@@ -0,0 +1,134 @@
---
name: creating-plugins
description: Create EmDash CMS plugins with sandboxed hooks, routes, storage, content and media APIs, MCP tools, and declarative admin UI, or native React and Astro extensions. Use when scaffolding or implementing an EmDash plugin.
---
# Creating EmDash plugins
Build against the API that reaches the intended execution mode. Source types and production-boundary tests take precedence over examples in this skill when they disagree.
## Choose a format
| Format | Runtime source | Admin UI | Distribution |
| --------- | --------------------------------------------------- | --------------------------------------------------------- | ---------------------------- |
| Sandboxed | `src/plugin.ts` default-exports a `SandboxedPlugin` | Block Kit pages, widgets, saved-entry panels, and actions | Plugin CLI and registry |
| Native | `definePlugin()` / `createPlugin()` | React, Block Kit, and Astro components | Trusted site dependency only |
Use a sandboxed plugin unless the feature needs host-process access, React admin code, Astro rendering components, raw page fragments, or custom Portable Text block definitions. Native plugins run with the site's authority and cannot be installed from the registry.
## Scaffold a sandboxed plugin
```sh
pnpm dlx @emdash-cms/plugin-cli init my-plugin
cd my-plugin
pnpm install
pnpm run test
```
The manifest is the identity and trust contract. Runtime hooks and routes live in `src/plugin.ts`; the CLI generates descriptors, manifests, and bundles. Do not create a separate descriptor factory or `sandbox-entry.ts`.
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
};
export default plugin;
```
Import authoring types from `emdash/plugin` with `import type`. Value imports are limited to lightweight helpers such as `pluginRoute()` and `pluginResponse()`, which the CLI bundles. Sandboxed runtime code can use Web APIs but not Node.js built-ins.
## Declare access
Declare every host API in `emdash-plugin.jsonc`. Adding authority, exposing a route publicly, or adding MCP tools requires renewed administrator approval.
| Capability | Grants |
| -------------------------------- | -------------------------------------------------------------------------- |
| `schema:read` | Public collection and field definitions |
| `admin.editor-draft:read` | Selected unsaved field values after an explicit editor interaction |
| `admin.editor-draft:patch` | Host-validated unsaved field changes proposed for editor review |
| `content:read` | Content identity, translations, and published public URLs |
| `content:revisions:read` | Retained revision data; implies content read |
| `content:write` | Create, update, delete, and translation creation; implies read |
| `content:publish` | Revision-fenced publish, unpublish, schedule, and unschedule; implies read |
| `content:restore` | Revision-fenced reads and restoration of trashed content |
| `hooks.content-policy:register` | Pre-publish, pre-schedule, and pre-unpublish policy hooks |
| `taxonomies:read` | Taxonomy definitions, terms, and entry assignments |
| `taxonomies:write` | Term creation and assignment deltas; implies read |
| `redirects:read` | Versioned redirect inspection |
| `redirects:write` | Versioned redirect creation, update, and deletion; implies read |
| `comments:read` | Stored non-trashed comments and their personal data |
| `comments:moderate` | Expected-status moderation; implies read |
| `media:read` | Ready-media metadata and authenticated asset URLs |
| `media:bytes:read` | Bounded media bytes and content hashes |
| `media:metadata:write` | Alt text, caption, and focal-point updates |
| `media:write` | Upload and delete; implies media read |
| `network:request` | `ctx.http.fetch()` restricted to `allowedHosts` |
| `network:request:unrestricted` | `ctx.http.fetch()` without a host list |
| `users:read` | User directory lookup; also required by comment hooks |
| `email:send` | Email delivery when a transport is configured |
| `hooks.email-transport:register` | Exclusive `email:deliver` hook |
| `hooks.email-events:register` | Email before/after hooks |
| `hooks.page-fragments:register` | Trusted-only page fragments; excluded from sandbox registration |
Settings, KV, declared storage, logging, and cron scheduling are plugin-scoped and need no capability. Use `ctx.settings` for user configuration, `ctx.kv` for internal key-value state, and declared `ctx.storage.<collection>` for queryable records.
Read the focused reference for the API being used:
- [Content, schema, translations, and publication](./references/content.md)
- [Taxonomies and redirects](./references/taxonomies-and-redirects.md)
- [Comments](./references/comments.md)
- [Media](./references/media.md)
- [Storage, KV, and encrypted settings](./references/storage.md)
## Routes, HTTP, and MCP
Routes are private by default and require authentication, their declared RBAC permission, token scope or CSRF as appropriate. Public routes are internet-facing and require explicit install consent.
Undeclared routes keep the legacy JSON/query envelope. Declare methods and body modes for host-enforced parsing. Use `pluginResponse()` only on a route that declares a raw response. Outbound `ctx.http.fetch()` responses and declared route bodies are buffered and bounded.
MCP tools reference private JSON routes with explicit permissions and Zod input schemas. Mark difficult-to-reverse operations `destructive: true`. Read [API routes and MCP tools](./references/api-routes.md).
## Hooks
Sandboxed hooks enter the same priority, dependency, timeout, error-policy, enablement, exclusive-provider, and capability pipeline as trusted hooks. Publication policy hooks identify action origin and actor without granting publication authority. Comment moderation invoked through `ctx.comments` reports plugin origin and runs the normal after-hook once. Read [Hooks](./references/hooks.md).
## Declarative admin UI
Sandboxed pages and widgets return validated Block Kit. Structured links use host-resolved targets; `routeCtx.ui` carries host-attested locale, direction, and surface. External images require matching network authority.
Saved-entry panels and actions point to private routes. Ordinary panel load receives only host-reloaded saved identity and version. Add `admin.editor-draft:read` or `admin.editor-draft:patch` plus extension-level collection and field selectors when an explicit interaction must receive selected unsaved fields or propose an atomic whole-field patch. Patch does not imply read. The host previews accepted patches, marks the form dirty, and never saves them automatically. Read saved content through capability-gated `ctx.content`.
Declarative field widgets currently compose supported Block Kit elements into a JSON value. Custom Portable Text blocks and Astro render components remain native-only. Read [Admin UI](./references/admin-ui.md), [Block Kit](./references/block-kit.md), and [Portable Text blocks](./references/portable-text-blocks.md).
## Runner parity
Cloudflare Worker Loader and Node/workerd execute the same bundle behind a plugin-scoped bridge. Both return buffered WHATWG responses from `ctx.http.fetch()` with binary bytes preserved and decoded request/response bodies limited to 8 MiB. Write against exported types, not extra methods found in one wrapper.
Read [Sandbox boundaries](./references/sandbox-boundaries.md) before designing around an API not listed here.
## Test the production boundary
Use `createPluginTestHost()` for fast hook, route, manifest, capability, KV, settings, and storage transport tests. Use `createPluginRuntimeTestHost()` when the test must exercise real content actions, plugin activation, media, comments, redirects, scheduling, restart, authorization, CSRF, caching, Block Kit validation, or saved-entry extensions.
Runtime fixtures establish state without firing hooks. Runtime actions call production boundaries; inspectors read observable state. Queue outbound HTTP responses with `host.http.respond()` and inspect requests with `host.http.requests()`. Dispose every host after use. Add Node/workerd parity only for runner-sensitive behavior.
## References
- [Content, schema, translations, and publication](./references/content.md)
- [Taxonomies and redirects](./references/taxonomies-and-redirects.md)
- [Comments](./references/comments.md)
- [Media](./references/media.md)
- [Hooks](./references/hooks.md)
- [Storage, KV, and encrypted settings](./references/storage.md)
- [Admin UI and field widgets](./references/admin-ui.md)
- [API routes and MCP tools](./references/api-routes.md)
- [Block Kit](./references/block-kit.md)
- [Portable Text blocks](./references/portable-text-blocks.md)
- [Sandbox boundaries](./references/sandbox-boundaries.md)
- [Publishing](./references/publishing.md)
@@ -0,0 +1,211 @@
# Admin UI and field widgets
Sandboxed plugins return declarative Block Kit from a private route. Native plugins may instead ship React components. Keep these paths separate: sandboxed plugin JavaScript never runs in the browser.
## Sandboxed pages and dashboard widgets
Declare navigation and widget cards in `emdash-plugin.jsonc`:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"pages": [
{ "path": "/settings", "label": "Settings", "icon": "settings" },
{ "path": "/reports", "label": "Reports", "icon": "chart" },
],
"widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }],
},
}
```
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`. Widget sizes are `full`, `half`, and `third`.
Any sandboxed plugin that declares a page or widget must define an `admin` route. The admin sends a `page_load`, `block_action`, or `form_submit` interaction as `routeCtx.input`:
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import type { BlockResponse } from "@emdash-cms/blocks";
import { z } from "zod";
const interactionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("page_load"), page: z.string() }),
z.object({
type: z.literal("block_action"),
action_id: z.string(),
block_id: z.string().optional(),
value: z.unknown().optional(),
}),
z.object({
type: z.literal("form_submit"),
action_id: z.string(),
block_id: z.string().optional(),
values: z.object({ enabled: z.boolean() }),
}),
]);
function settingsForm(enabled: boolean): BlockResponse {
return {
blocks: [
{ type: "header", text: "Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled },
],
submit: { action_id: "save", label: "Save" },
},
],
};
}
const plugin: SandboxedPlugin = {
routes: {
admin: {
permission: "plugins:manage",
handler: async (routeCtx, ctx) => {
const parsed = interactionSchema.safeParse(routeCtx.input);
if (!parsed.success) return { blocks: [] };
const interaction = parsed.data;
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.settings.set("enabled", interaction.values.enabled === true);
return {
...settingsForm(interaction.values.enabled === true),
toast: { type: "success", message: "Settings saved" },
};
}
const enabled = (await ctx.settings.get<boolean>("enabled")) ?? false;
return settingsForm(enabled);
},
},
},
};
export default plugin;
```
Validate interactions before production side effects; `routeCtx.input` is `unknown`. Read [Block Kit](./block-kit.md) for exact interaction, block, and element shapes.
The plugin CLI preserves `admin.settingsSchema` in the registry manifest and generated descriptor, so the host can generate a settings form. Both sandbox bridges route `ctx.settings` through the same options records as that form. Read a generated setting with `ctx.settings.get("<key>")`; writes, deletes, list operations, and revision-based operations use the same namespace on Cloudflare and Node/workerd.
The `secret` settings field is write-only in the admin response and encrypted before persistence. The site must provide `EMDASH_ENCRYPTION_KEY`; missing, wrong, or tampered key material fails closed. Keep the encryption-key list with database backups. Existing `ctx.kv.get("settings:<key>")` reads remain compatible throughout EmDash 1.x.
## Sandboxed saved-entry extensions
Declare saved-entry panels and actions in `emdash-plugin.jsonc`:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"editorPanels": [
{
"id": "health",
"title": "Content health",
"route": "editor/health",
"collections": ["posts"],
"draft": {
"read": { "translatable": true },
"patch": { "fields": ["title", "excerpt"] },
},
},
],
"editorActions": [
{
"id": "repair",
"label": "Repair metadata",
"route": "editor/repair",
"placement": "overflow",
"style": "danger",
"confirm": {
"title": "Repair?",
"text": "This changes the saved entry.",
"confirm": "Repair",
"deny": "Cancel",
},
},
],
},
}
```
Every referenced route must be private. EmDash reloads the saved entry and checks ownership plus the route permission before invoking it. `routeCtx.ui.entry` contains only the canonical collection, ID, locale, and version.
Panels start collapsed. `panel_load` never includes draft values. After an explicit `block_action`, `form_submit`, or `editor_action`, `admin.editor-draft:read` can attach only the fields selected by the extension's `draft.read` declaration. `fields` selects explicit slugs and `translatable: true` selects current schema fields marked translatable. Draft declarations require explicit collection scope.
`admin.editor-draft:patch` permits a separate `draft.patch` field selector and does not imply read. Return `patch: { type: "editor-draft-patch", operations }` with whole-field `set` or `clear` operations. The host rejects the complete patch on an unknown, forbidden, invalid, unsupported, oversized, or stale operation. Accepted patches receive a host-rendered preview, update the form atomically, mark it dirty, and remain unsaved. A response may contain a toast and one terminal effect: patch, refresh, or navigation.
Use `createPluginRuntimeTestHost().admin` to exercise this boundary. `captureEditorDraft()` creates a saved-entry draft request, the existing panel/action helpers invoke the production route, and `applyEditorDraftPatch()` applies only a current response through the host validator.
## Sandboxed declarative field widgets
Core and the admin contain a declarative field-widget path. Declare the widget in the registry manifest:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"fieldWidgets": [
{
"name": "event-picker",
"label": "Event",
"fieldTypes": ["json"],
"elements": [
{ "type": "text_input", "action_id": "eventId", "label": "Event ID" },
{ "type": "toggle", "action_id": "featured", "label": "Featured" },
],
},
],
},
}
```
A schema field selects it with `widget: "pluginId:widgetName"`. The editor stores an object keyed by each element's `action_id`. Use a `json` field for this object. The manifest schema accepts other compatible field types, but the repository has no end-to-end test proving that the composed object saves through them.
The current field-widget renderer supports:
- `text_input`
- `number_input`
- `toggle`
- `select`
- `media_picker`
Other Block Kit element types display an unsupported-element message in this surface.
`emdash-plugin.jsonc` accepts `admin.fieldWidgets`, and the plugin CLI carries the definitions through the bundle manifest and generated descriptor for registry installation. The artifact round-trip is covered by plugin CLI, shared manifest, and plugin-test tests. The browser E2E fixture still tests a native React color picker rather than a registry-installed declarative widget, so verify the real editor render and value persistence for the chosen elements.
The sandbox admin route receives `routeCtx.ui` with the host-attested admin locale, text direction, and surface. Use it to select localized text in a runtime Block Kit response. Labels in manifest metadata remain static strings; registry plugins do not hand translation catalogs to the host.
## Native React pages, widgets, and fields
Native plugins may set `admin.entry` and export React components:
```typescript title="src/admin.tsx"
export const pages = {
"/settings": SettingsPage,
};
export const widgets = {
status: StatusWidget,
};
export const fields = {
picker: ColorPickerField,
};
```
The plugin definition points to the entry and declares its surfaces:
```typescript
definePlugin({
id: "color",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-color/admin",
pages: [{ path: "/settings", label: "Settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }],
},
});
```
Native admin code must follow the repository's Kumo, localization, accessibility, and RTL rules. It runs with the site's authority and is not registry-installable.
@@ -0,0 +1,245 @@
# API routes and MCP tools
Sandboxed plugin routes are mounted at `/_emdash/api/plugins/<slug>/<route-name>`. Define them in the typed default export from `src/plugin.ts`.
## Define a route
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionInput = z.object({
formId: z.string().min(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
const plugin: SandboxedPlugin = {
routes: {
submissions: {
permission: "content:read",
handler: async (routeCtx, ctx) => {
const parsed = submissionInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_INPUT" };
const result = await ctx.storage.submissions.query({
where: { formId: parsed.data.formId },
limit: parsed.data.limit,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
```
The handler receives two arguments:
```typescript
interface SandboxedRouteContext {
input: unknown;
request: {
url: string;
method: string;
headers: Record<string, string>;
};
requestMeta?: {
ip: string | null;
userAgent: string | null;
referer: string | null;
geo: { country: string | null; region: string | null; city: string | null } | null;
};
user?: UserInfo;
}
```
`routeCtx.request` is a serialized record in both sandbox runners, not a WHATWG `Request`. Header keys are lowercased. Read parsed request data from `routeCtx.input`; the body has already been consumed by the host.
The published authoring type currently declares `requestMeta` as `unknown`, although both runners send the normalized shape shown above. Narrow it before reading fields when TypeScript cannot infer the shape.
## Input sources and validation
Undeclared routes keep the original input contract: the host parses JSON for `POST`, `PUT`, and
`PATCH`, and parses the query string for `GET`, `HEAD`, and `DELETE`. Repeated query keys become
arrays. Validate the resulting `unknown` value inside the handler.
Declare `request.body` as `none`, `json`, `text`, `bytes`, or `form-data` to select a parser. Bodies
are buffered with a 1 MiB default maximum; `maxBytes` can raise the limit to 8 MiB. Use
`pluginRoute()` from `emdash/plugin` to infer `PluginRouteQuery`, `string`, `Uint8Array`, or
`PluginFormData` from the mode. JSON remains `unknown` for validation.
Form data accepts multipart and URL-encoded requests. The multipart limits are 100 parts, 1 MiB per
part, and 255 UTF-8 bytes for a safe filename. The total request must also fit the route body limit.
Declare request header names explicitly in `request.headers`. Only those safe headers reach the
handler. Credentials, cookies, Cloudflare Access headers, proxy authorization, `Set-Cookie`, and
the EmDash CSRF header cannot be declared and never cross the sandbox boundary.
The plugin CLI probe currently does not retain a route entry's `input` schema, so do not rely on route-level Zod validation for a built sandboxed plugin. An MCP tool still requires its own Zod input schema.
## Authentication, permission, and CSRF
Routes are private unless they set `public: true`.
A private route requires:
- an authenticated session, or a token with the `admin` scope;
- the route's declared EmDash RBAC `permission`, defaulting to `plugins:manage`;
- `X-EmDash-Request: 1` for cookie-authenticated calls, for every HTTP method.
The host resolves these checks before invoking the plugin. `routeCtx.user` then contains the authenticated caller for a user-bound request:
```typescript
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
```
Caller identity is not gated by `users:read`; it identifies the current authorized caller. `ctx.users` is a directory lookup and does require `users:read`. `routeCtx.user` is absent on public routes and on machine-token calls without a bound user.
A public route skips authentication, permission, and token-scope checks. It is internet-facing, so validate input, check the intended HTTP method, and verify webhook signatures or shared tokens where applicable.
Public exposure is reviewed during installation. Adding a public route or changing a private route
to public requires approval again on update.
## HTTP methods
The route name selects one handler. Declare `methods` to have the host reject other methods with
`405 Method Not Allowed` and an `Allow` header before the handler runs:
```typescript
methods: ["POST"],
handler: async (routeCtx, ctx) => {
// Validate input, then mutate.
},
```
Routes without `methods` remain method-agnostic. Check the method inside legacy handlers that mutate
state, or add a declaration.
## Results and errors
Return a JSON-serializable value. The HTTP endpoint wraps it in EmDash's `{ success: true, data }` envelope.
Return a stable application-level error object for expected validation and domain failures. Throw only for unexpected failures, and keep exception messages free of credentials, personal data, paths, and stack traces.
Declare `response: "raw"` for unwrapped responses. The handler must return `pluginResponse()` from
`emdash/plugin` with a text or `Uint8Array` body. Raw response bodies are buffered up to 8 MiB.
The response header allowlist is `Accept-Ranges`, `Content-Disposition`, `Content-Encoding`,
`Content-Language`, `Content-Range`, `Content-Type`, `ETag`, `Last-Modified`, `Location`, and
`Retry-After`. The host removes other plugin-supplied headers, adds security headers, and applies the
route-owned cache policy.
Raw routes cannot return active same-origin types, including HTML, JavaScript or ECMAScript, XHTML,
SVG, XML, CSS, WebAssembly, `multipart/related`, and `multipart/x-mixed-replace`. A raw route also
cannot back an MCP tool. Do not return or throw a WHATWG `Response`; it is not the portable response
wire.
## Public caching
Core accepts `cacheControl` on a public route:
```typescript
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async () => ({ items: [] }),
},
},
```
The value is applied only to successful public `GET` and `HEAD` responses. Private responses, errors, and other methods remain `private, no-store`. The plugin CLI carries `cacheControl` through the probe, bundle manifest, registry artifact, and generated descriptor.
## Request metadata
Both runners send the same normalized metadata:
- `ip`: a trusted client address when the platform or operator configured a trusted proxy header, otherwise `null`;
- `userAgent`: the `User-Agent` value, otherwise `null`;
- `referer`: the `Referer` value, otherwise `null`;
- `geo`: Cloudflare country, region, and city when available, otherwise `null`.
Do not treat `userAgent`, `referer`, or geographic values as authenticated identity. Use `routeCtx.user` for the caller.
## Content reads
With `content:read`, both sandbox runners match the trusted read contract. `ctx.content.get()` and `ctx.content.list()` return content identity, slug, status, locale, data, created/updated/published/scheduled timestamps, and SEO metadata when enabled.
`list()` accepts `limit`, `cursor`, `where`, and `orderBy`. Field filters, status filters, ordering, and cursor pagination reach the host repository on both runners; they are not evaluated inside the plugin isolate. `getTranslations()` and `getPublicUrl()` use the same `content:read` authority. Collection discovery uses `ctx.schema` with `schema:read`. Read [Content, schema, translations, and publication](./content.md) for the complete contract.
## Publication actions
With `content:publish`, call `getVersioned()` before `publish()`, `unpublish()`, `schedule()`, or `unschedule()`, then pass the returned `_rev`. Each successful mutation returns the item and its next `_rev`. `content:restore` separately provides `getTrashedVersioned()` and `restore()` without granting ordinary content reads. These actions execute through the host runtime, including policy and after-hooks; a stale revision or recursive action rejects without changing the entry.
## Redirects
Declare `redirects:read` to list redirect rules with cursor pagination and read one rule with its opaque `_rev`. Declare `redirects:write` to create, update, or delete rules; it implies read access and authorizes the plugin to change where visitors are sent.
Pass `_rev` back unchanged for `update()` and `delete()`. A stale value fails with `CONFLICT`; re-read the current rule before retrying. The revision tracks redirect configuration, not visitor hit counting. Redirect writes use the host redirect handlers, including path-pattern, destination-parameter, duplicate-source, and terminal-status validation. Self-loop and multi-hop-loop validation runs when a rule is created or its source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. Plugin input cannot set the host-owned `auto` marker.
## External HTTP responses
`ctx.http.fetch()` returns a buffered WHATWG `Response` in both sandbox runners. `ok`, `status`, `statusText`, `headers`, `url`, `redirected`, `text()`, `json()`, `arrayBuffer()`, `blob()`, and `clone()` use the standard Web API and preserve the same values across runners.
Request and response bodies are each limited to 8 MiB of decoded bytes. The bridge enforces the limit while reading the body rather than trusting `Content-Length`. Responses are buffered rather than streamed to plugin code.
## Expose a route as an MCP tool
MCP exposure is explicit. The following tool calls the private route as `<pluginId>__createEvent`:
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
return { ok: true, id: crypto.randomUUID() };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event requested by the user.",
route: "events/create",
input: createEventInput,
output: z.object({ ok: z.boolean(), id: z.string().optional() }),
destructive: false,
},
},
},
};
export default plugin;
```
An MCP tool must:
- use a tool name containing only letters, digits, `_`, or `-`;
- reference an existing private route;
- reference a route with an explicit valid `permission`;
- reference a JSON route, not a route with `response: "raw"`;
- declare an input Zod schema;
- set `destructive: true` for deletion, overwrite, publishing, charging, or another difficult-to-reverse action.
The optional output schema becomes structured MCP output. The bundle converts both schemas to JSON Schema.
Installation and updates show the exact MCP tools for consent. Adding a tool or changing a route from private to public requires fresh approval. After installation, an administrator separately enables plugin MCP tools. A caller then needs the route permission and either the `mcp:tools` scope or `mcp:tools:<pluginId>`.
The production core parser, shared plugin-types parser, plugin CLI artifact, and generated descriptor preserve MCP declarations and route permission/cache metadata. `@emdash-cms/plugin-test` exposes the parsed manifest so tests can assert that transport. Its `invokeRoute()` still bypasses the HTTP catch-all, so use the host for plugin execution and bridge behavior, not route authorization, response caching, MCP registration, or consent behavior.
@@ -0,0 +1,584 @@
# Block Kit
Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.
Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.
Native plugins also use Block Kit elements for [Portable Text block editing fields](./portable-text-blocks.md). Plugin CLI and registry packages cannot register Portable Text block types.
## How It Works
1. User navigates to plugin admin page
2. Admin sends `page_load` interaction to plugin's admin route
3. Plugin returns `BlockResponse` with array of blocks
4. Admin renders blocks using `BlockRenderer`
5. User interacts (button click, form submit) → interaction sent back
6. Plugin returns new blocks
```typescript
import type { BlockInteraction } from "@emdash-cms/blocks";
routes: {
admin: {
handler: async (ctx) => {
// EmDash parses the request body once and exposes it as ctx.input;
// read it directly rather than ctx.request.json() (the body is consumed).
// BlockInteraction is the discriminated union of page_load,
// block_action, and form_submit payloads.
const interaction = ctx.input as BlockInteraction;
if (interaction.type === "page_load") {
return {
blocks: [
{ type: "header", text: "My Plugin Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.kv.set("settings", interaction.values);
return {
blocks: [/* updated blocks */],
toast: { message: "Settings saved", type: "success" },
};
}
},
},
}
```
## 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 alt text and optional title |
| `context` | Small muted help text |
| `columns` | 2-3 column layout with nested blocks |
| `chart` | Charts (timeseries line/bar, pie, custom ECharts) |
| `code` | Syntax-highlighted code block |
| `meter` | Progress/quota meter bar |
| `banner` | Info, warning, or error inline messages |
| `empty` | Empty state with optional command and actions |
| `accordion` | Collapsible section containing nested blocks |
## Element Types
| Type | Description |
| -------------- | --------------------------------------------------------- |
| `button` | Action button with optional confirmation dialog |
| `link` | Host-resolved navigation that does not dispatch an action |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |
| `checkbox` | Multi-select checkboxes |
| `radio` | Single-select radio buttons |
| `date_input` | Date picker |
| `combobox` | Searchable dropdown select |
| `repeater` | Array of records with scalar sub-fields |
| `media_picker` | Media-library picker that stores the asset URL |
## Block Syntax
### Header
```json
{ "type": "header", "text": "Settings" }
```
### Section
```json
{
"type": "section",
"text": "Configure your plugin settings below.",
"accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" }
}
```
### Divider
```json
{ "type": "divider" }
```
### Fields
```json
{
"type": "fields",
"fields": [
{ "label": "Status", "value": "Active" },
{ "label": "Last Sync", "value": "2 hours ago" }
]
}
```
### Stats
```json
{
"type": "stats",
"items": [
{ "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" },
{ "label": "Active", "value": "567" }
]
}
```
- `items` — the array key is `items`, not `stats`
- `trend` — `"up"`, `"down"`, or `"neutral"`; renders an arrow icon next to the value
- `description` — secondary line beneath the value, for context such as "+12% vs last week"
### Table
```json
{
"type": "table",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "status", "label": "Status" },
{ "key": "date", "label": "Date" }
],
"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }],
"page_action_id": "browse_items",
"empty_text": "No items yet."
}
```
- `page_action_id` — required. The admin sends it as the `block_action` id when the user sorts a column or pages through results.
- `empty_text` — shown in place of the table when `rows` is empty
- `next_cursor` — set it to render a "Load more" control
### Actions
```json
{
"type": "actions",
"elements": [
{ "type": "button", "label": "Save", "action_id": "save", "style": "primary" },
{ "type": "button", "label": "Cancel", "action_id": "cancel" }
]
}
```
### Form
```json
{
"type": "form",
"block_id": "settings",
"fields": [
{ "type": "text_input", "action_id": "name", "label": "Name" },
{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
{
"type": "select",
"action_id": "theme",
"label": "Theme",
"options": [
{ "label": "Light", "value": "light" },
{ "label": "Dark", "value": "dark" }
]
},
{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
],
"submit": { "label": "Save", "action_id": "save_settings" }
}
```
### Columns
```json
{
"type": "columns",
"columns": [
[
{ "type": "header", "text": "Usage" },
{ "type": "meter", "label": "Storage used", "value": 65 }
],
[
{ "type": "header", "text": "Activity" },
{ "type": "context", "text": "Last sync 2 hours ago" }
]
]
}
```
- `columns` — 2 or 3 columns, each one an array of blocks
### Chart (Timeseries)
```json
{
"type": "chart",
"config": {
"chart_type": "timeseries",
"series": [
{
"name": "Requests",
"data": [
[1709596800000, 42],
[1709600400000, 67],
[1709604000000, 53]
],
"color": "#086FFF"
},
{
"name": "Errors",
"data": [
[1709596800000, 2],
[1709600400000, 5],
[1709604000000, 1]
]
}
],
"x_axis_name": "Time",
"y_axis_name": "Count",
"style": "line",
"gradient": true,
"height": 300
}
}
```
- `series[].data` — array of `[timestamp_ms, value]` tuples
- `series[].color` — hex color (optional, auto-assigned from Kumo palette)
- `style` — `"line"` (default) or `"bar"`
- `gradient` — fill gradient beneath lines (default false)
- `height` — chart height in pixels (default 350)
### Chart (Custom)
For pie charts, gauges, or any ECharts visualization:
```json
{
"type": "chart",
"config": {
"chart_type": "custom",
"options": {
"series": [
{
"type": "pie",
"data": [
{ "value": 335, "name": "Published" },
{ "value": 234, "name": "Draft" },
{ "value": 120, "name": "Scheduled" }
]
}
]
},
"height": 300
}
}
```
- `options` — raw ECharts option object passed through to `chart.setOption()`
### Code
```json
{
"type": "code",
"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
"language": "ts"
}
```
- `language` — `"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`)
### Meter
```json
{
"type": "meter",
"label": "Storage used",
"value": 65,
"custom_value": "6.5 GB / 10 GB"
}
```
- `value` — numeric value (default range 0-100)
- `max` / `min` — custom range (defaults to 0-100)
- `custom_value` — display string instead of percentage (e.g. "750 / 1,000")
### Banner
```json
{
"type": "banner",
"title": "API key invalid",
"description": "Please check your API key in settings.",
"variant": "error"
}
```
- `variant` — `"default"` (info, default), `"alert"` (warning), or `"error"`
- At least one of `title` or `description` is required
### Empty
```json
{
"type": "empty",
"title": "No submissions",
"description": "New submissions appear here.",
"command_line": "pnpm run seed",
"size": "base",
"actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }]
}
```
### Tabs
Use `tab` to group related blocks into labelled panels:
```json
{
"type": "tab",
"panels": [
{
"label": "General",
"blocks": [{ "type": "context", "text": "General settings" }]
}
]
}
```
### Accordion
```json
{
"type": "accordion",
"label": "Advanced settings",
"default_open": false,
"blocks": [{ "type": "context", "text": "Settings visible when expanded" }]
}
```
## Repeater and media picker elements
`repeater` and `media_picker` are admin-authoring elements, not ordinary fields in a sandboxed admin-page `form`.
`repeater` captures an array of objects. Its nested fields are limited to `text_input`, `number_input`, `select`, and `toggle`:
```typescript
{
"type": "repeater",
"action_id": "items",
"label": "Questions",
"item_label": "Question",
"fields": [
{ "type": "text_input", "action_id": "question", "label": "Question" },
{ "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true }
]
}
```
`media_picker` opens the media library and stores the selected asset's URL string:
```typescript
{
"type": "media_picker",
"action_id": "hero",
"label": "Hero image",
"mime_type_filter": "image/"
}
```
## Declarative field widgets
The admin field editor can render a plugin field widget from Block Kit elements. A schema field refers to `pluginId:widgetName`; a config-declared standard descriptor supplies `name`, `label`, compatible `fieldTypes`, and `elements`.
The field-widget renderer currently supports only these elements:
- `text_input`
- `number_input`
- `toggle`
- `select`
- `media_picker`
It stores an object keyed by each element's `action_id`. Use a `json` field for this composed value; other field types are accepted by the manifest schema but are not covered by an end-to-end save test. Other element types render an unsupported-element message.
`emdash-plugin.jsonc` accepts this field-widget definition, and the plugin CLI preserves it in the registry manifest and generated descriptor. The artifact transport is tested; the repository's browser E2E test still covers a native React field widget rather than a registry declarative widget. Verify the rendered editor and saved value for the selected elements.
## Conditional Fields
Show/hide fields based on other field values. Evaluated client-side, no round-trip.
```json
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
```
```json
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
```
## Builder Helpers
`@emdash-cms/blocks` provides TypeScript helpers:
```typescript
import { blocks, elements } from "@emdash-cms/blocks";
const {
header,
form,
section,
stats,
timeseriesChart,
customChart,
banner: bannerBlock,
empty,
accordion,
} = blocks;
const { textInput, toggle, select, button, repeater, mediaPicker } = elements;
return {
blocks: [
header("Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
// Timeseries chart
timeseriesChart({
series: [
{
name: "Page Views",
data: [
[Date.now() - 3600000, 100],
[Date.now(), 150],
],
},
],
yAxisName: "Views",
gradient: true,
}),
// Pie chart via custom ECharts options
customChart({
options: {
series: [
{
type: "pie",
data: [
{ value: 335, name: "Published" },
{ value: 234, name: "Draft" },
],
},
],
},
}),
],
};
```
## Button Confirmations
```json
{
"type": "button",
"label": "Delete All",
"action_id": "delete_all",
"style": "danger",
"confirm": {
"title": "Are you sure?",
"text": "This cannot be undone.",
"confirm": "Delete",
"deny": "Cancel"
}
}
```
## Saved-entry panels and actions
`admin.editorPanels` and `admin.editorActions` point to private plugin routes. Panel routes return Block Kit and receive `panel_load`, `block_action`, or `form_submit`. Action routes receive `editor_action` and return only these bounded fields:
```typescript
{
toast?: { message: string; type: "success" | "error" | "info" };
refresh?: true;
navigate?: LinkTarget;
patch?: EditorDraftPatchEffect;
}
```
Use only one of `refresh`, `navigate`, or `patch`. Navigation uses the same structured target validator as `link` elements. A danger action declaration must include a confirmation dialog.
For both surfaces, `routeCtx.ui.entry` contains the host-reloaded collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the manifest declaration. Ordinary panel load contains no draft. An explicit interaction includes selected unsaved fields only when the plugin has `admin.editor-draft:read` and the extension declares bounded `draft.read` access. `admin.editor-draft:patch` separately permits atomic whole-field `set` and `clear` proposals for fields in `draft.patch`; the host previews accepted changes without saving them.
## Links and admin locale
Use a structured link target instead of returning an admin URL:
```json
{
"type": "link",
"label": "Edit article",
"target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" },
"appearance": "primary"
}
```
Targets can identify saved content, a page declared by the same plugin, the plugin's generated settings page, or an absolute external HTTP, HTTPS, or `mailto:` URL. External links open in a new tab with `noopener noreferrer`. A link has no `action_id`; use a button when the interaction must call the plugin.
Every page and widget response is validated before rendering. Responses are limited to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string. Root-relative image URLs are accepted. External images require HTTPS and either `network:request` with the hostname in the plugin manifest's `allowedHosts`, or `network:request:unrestricted`.
The admin route receives host-attested UI context separately from the interaction:
```typescript
const { locale, direction, surface } = routeCtx.ui ?? {
locale: "en",
direction: "ltr",
surface: "admin-page",
};
```
The UI locale is the administrator's active locale. It is separate from the site's default content locale in `ctx.site.locale`. Runtime page labels can use it to select localized Block Kit text; manifest navigation labels remain static.
## Toast Responses
Return a `toast` alongside blocks to show a notification:
```typescript
return {
blocks: [/* ... */],
toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
};
```
@@ -0,0 +1,13 @@
# Comment administration
Comment hooks and stored-comment administration have separate authority.
All comment hooks require `users:read` because their events contain author and request-derived information. `comments:read` separately exposes stored non-trashed comments through `ctx.comments.get()`, `list()`, and `count()`.
Comment reads include author name and email, body, pseudonymous IP hash, user agent, and moderation metadata. They do not expose the linked EmDash user-account ID. Treat this as personal-data access.
`comments:moderate` implies read and adds `ctx.comments.setStatus()`. Supply the status observed by the caller and move only between `approved`, `pending`, and `spam`. A stale status returns `COMMENT_STATUS_CONFLICT`; read again before deciding whether to retry.
A successful plugin transition identifies `{ source: "plugin", pluginId }`, runs `comment:afterModerate` once, preserves approval notifications, and rejects recursive moderation. The API does not hard-delete comments or replace statuses in bulk.
Use the runtime test host when a test must prove the personal-data shape, conflict behavior, notifications, hook origin, recursion fencing, or the real comment HTTP/runtime path.
@@ -0,0 +1,41 @@
# Content, schema, translations, and publication
Plugin content APIs are capability-gated and shared by native, Cloudflare Worker Loader, and Node/workerd execution.
## Discovery and reads
`schema:read` exposes `ctx.schema.listCollections()` and `getCollection()` for batched public collection and field definitions.
`content:read` exposes `ctx.content.get()`, `list()`, `getTranslations()`, and `getPublicUrl()`. Content results include identity, slug, status, locale, data, timestamps, author ID, translation group, live and draft revision pointers, and row version. Public URL resolution returns only published routable URLs, never previews.
`content:revisions:read` adds `listRevisions()` and `getRevision()`. Revision snapshots can retain field values removed later but omit revision-author identity.
## Writes and translations
`content:write` adds create, update, and delete. Create a translation with:
```typescript
await ctx.content!.create("posts", data, { locale: "fr", translationOf: sourceId });
```
The source must be an active entry in the same collection. The new row joins its translation group and inherits non-translatable fields, byline credits, and taxonomy assignments. Validation and save hooks run, with re-entrancy fencing for the creating plugin. A group permits one active row per locale.
Stable failures include `CONFLICT`, `NOT_FOUND`, `VALIDATION_ERROR`, and `SAVE_REJECTED`.
## Publication policy
`hooks.content-policy:register` enables `content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` without granting content reads, writes, or publication actions. Return `{ cancel: true, reason }` to reject an action.
Events identify API, MCP, visual-editor, plugin, scheduler, and system origins. Authenticated human actions include actor identity and source. Scheduled publication runs the publish policy again; rejection unschedules the entry and records the bounded reason for administrators.
## Publication and restore actions
`content:publish` adds `getVersioned()`, `publish()`, `unpublish()`, `schedule()`, and `unschedule()`. Read first and pass the opaque `_rev` to every mutation. A successful action returns the next revision and runs the normal policy, synchronization, media-usage, cache-invalidation, and after-hook behavior.
`content:restore` separately adds `getTrashedVersioned()` and `restore()` without granting ordinary content reads. It does not grant permanent deletion.
Plugin actions report `{ source: "plugin", pluginId }`. Re-entering the same action for the same canonical entry from that plugin is rejected.
## Runtime tests
Use runtime fixtures for initial entries, translations, bylines, and taxonomy assignments. Use runtime actions for publication paths and inspectors for persisted state. Test stale revisions, policy rejection, duplicate locales, re-entrancy, restart, and cache invalidation when those behaviors matter.
@@ -0,0 +1,585 @@
# Hooks
Sandboxed plugins declare hooks in the typed default export from `src/plugin.ts`. The `SandboxedPlugin` type infers each event and return type from the hook name.
## Signature
```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```
## Configuration
Simple handler or full config:
```typescript
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": {
priority: 100,
timeout: 5000,
handler: async (event, ctx) => {
ctx.log.info("Saved", { id: event.content.id });
},
},
},
};
export default plugin;
```
The plugin CLI manifest retains `priority`, `timeout`, `dependencies`, `errorPolicy`, and `exclusive`. Registry-installed and config-managed sandbox hooks enter the shared host pipeline, so these settings apply consistently with trusted plugins.
Put capabilities in `emdash-plugin.jsonc`, not in `src/plugin.ts`. The host pipeline skips sandboxed and trusted hooks whose required capability is absent.
## Isolated sandbox support
The current isolated runtime dispatches these hooks to Cloudflare and Node/workerd sandbox instances through the shared host pipeline:
- `plugin:install`, `plugin:activate`, `plugin:deactivate`, and `plugin:uninstall`
- `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, and `content:afterDelete`
- `content:beforePublish`, `content:beforeSchedule`, `content:beforeUnpublish`, `content:afterPublish`, `content:afterUnpublish`, `content:afterRestore`, `content:afterSchedule`, and `content:afterUnschedule`
- `media:beforeUpload` and `media:afterUpload`
- `email:beforeSend`, `email:deliver`, and `email:afterSend`
- `comment:beforeCreate`, `comment:moderate`, `comment:afterCreate`, and `comment:afterModerate`
- `cron`
- `page:metadata`
`page:fragments` is the only declared hook excluded from sandbox registration. The plugin CLI accepts it and emits a trusted-only warning; the sandbox host proxy drops it before the hook pipeline is built.
## Lifecycle Hooks
### `plugin:install`
Runs once on first install. Use to seed defaults.
```typescript
"plugin:install": async (_event, ctx) => {
await ctx.settings.set("enabled", true);
await ctx.storage.items!.put("default", { name: "Default" });
}
```
Event: `{}`
Returns: `void`
### `plugin:activate`
Runs when plugin is enabled (after install or re-enable).
```typescript
"plugin:activate": async (_event, ctx) => {
ctx.log.info("Activated");
}
```
Event: `{}`
Returns: `void`
### `plugin:deactivate`
Runs when plugin is disabled (not removed).
```typescript
"plugin:deactivate": async (_event, ctx) => {
ctx.log.info("Deactivated");
}
```
Event: `{}`
Returns: `void`
### `plugin:uninstall`
Runs when plugin is removed. Only delete data if `event.deleteData` is true.
```typescript
"plugin:uninstall": async (event, ctx) => {
if (event.deleteData) {
const result = await ctx.storage.items!.query({ limit: 1000 });
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
}
}
```
Event: `{ deleteData: boolean }`
Returns: `void`
## Content Hooks
### `content:beforeSave`
Runs before save. Return modified content, or void to keep it unchanged. To reject from the sandbox, return `{ __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason } }`. The reason must be 1–500 characters of plain text. EmDash identifies the plugin and shows the reason as text; invalid or unknown error results fail with a generic hook error. From the host process, throw `ContentSaveRejectedError` (exported from `emdash`) instead. Any other exception from either execution mode fails the save with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
const { content } = event;
if (!content.title) {
return {
__emdashSandboxHookResult: true,
version: 1,
error: { code: "SAVE_REJECTED", reason: "Add a title before saving." },
};
}
// Transform
if (content.slug) {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
}
return content;
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean, id?: string, actor?: { id: string, role: number } }`. Authenticated REST, visual editing, and MCP saves include a read-only actor snapshot; internal writes may omit it. On updates, `id` identifies the existing item. The actor snapshot does not identify the request origin, so a hook cannot distinguish REST, visual editing, MCP, or another authenticated path from this field alone.
Returns: `Record<string, unknown> | SandboxHookErrorEnvelope | void`
### `content:afterSave`
Runs after successful save. Side effects only — logging, notifications, syncing.
```typescript
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean, actor?: { id: string, role: number } }`. Authenticated saves include a read-only actor snapshot; internal writes may omit it.
Returns: `void`
### `content:beforeDelete`
Runs before delete. Return `false` to cancel, `true` or void to allow.
```typescript
"content:beforeDelete": async (event, ctx) => {
if (event.collection === "pages" && event.id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
}
```
Event: `{ id: string, collection: string }`
Returns: `boolean | void`
### `content:afterDelete`
Runs after successful delete.
```typescript
"content:afterDelete": async (event, ctx) => {
ctx.log.info(`Deleted ${event.collection}/${event.id}`);
await ctx.storage.cache!.delete(`${event.collection}:${event.id}`);
}
```
Event: `{ id: string, collection: string }`
Returns: `void`
### Publication policy hooks
`content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` require `hooks.content-policy:register`. This authority is independent of `content:read`, `content:write`, and publication actions.
Publish and schedule events expose the effective draft in `content.data` and the staged slug in `content.slug`. Unpublish events expose the currently live content that the action would remove.
Return `void` to allow the action or `{ cancel: true, reason }` to reject it. The reason must contain 1–500 plain-text characters. Invalid decisions and unexpected abort-policy errors stop the action with a generic failure. Explicit cancellations return `PUBLISH_REJECTED`, `SCHEDULE_REJECTED`, or `UNPUBLISH_REJECTED`.
```typescript
"content:beforePublish": async (event) => {
const data = event.content.data;
const approvalStatus =
typeof data === "object" && data !== null && "approval_status" in data
? data.approval_status
: undefined;
if (approvalStatus !== "approved") {
return { cancel: true, reason: "Approve this entry before publishing." };
}
},
```
Events contain `{ content, collection, origin, actor? }`; `content:beforeSchedule` also contains `scheduledAt`. Human origins are `api`, `mcp`, or `visual-editor` and include the same value in `actor.source`. The visual-editor origin requires the signed short-lived token from an authenticated toolbar render. Other origins are `plugin` (with `pluginId`), `scheduler`, and `system`.
Scheduled content runs `content:beforePublish` again when it becomes due. An explicit scheduler rejection unschedules the entry and lists its reason on the dashboard until the entry is rescheduled, published, deleted, or the record is dismissed. There is no `content:beforeUnschedule`, so an administrator can always cancel a future publication.
### `content:afterPublish`
Runs after content is published (promoted from draft to live). Side effects only.
```typescript
"content:afterPublish": async (event, ctx) => {
ctx.log.info(`Published ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterUnpublish`
Runs after content is unpublished (reverted to draft). Side effects only.
```typescript
"content:afterUnpublish": async (event, ctx) => {
ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterRestore`
Runs after trashed content is restored. Side effects only.
```typescript
"content:afterRestore": async (event, ctx) => {
ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterSchedule`
Runs after content is scheduled for future publishing. Side effects only.
```typescript
"content:afterSchedule": async (event, ctx) => {
ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterUnschedule`
Runs after scheduled content is unscheduled. Side effects only.
```typescript
"content:afterUnschedule": async (event, ctx) => {
ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
## Media Hooks
### `media:beforeUpload`
Runs before upload. Return modified file info, void to keep, or throw to cancel.
```typescript
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
if (!file.type.startsWith("image/")) {
throw new Error("Only images allowed");
}
if (file.size > 10 * 1024 * 1024) {
throw new Error("Max 10MB");
}
return { ...file, name: `${Date.now()}-${file.name}` };
}
```
Event: `{ file: { name: string, type: string, size: number } }`
Returns: `{ name: string, type: string, size: number } | void`
### `media:afterUpload`
Runs after successful upload.
```typescript
"media:afterUpload": async (event, ctx) => {
ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
}
```
Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }`
Returns: `void`
## Email Hooks
Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.
### `email:beforeSend`
**Requires:** `hooks.email-events:register` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:beforeSend": async (event) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `EmailMessage | false`
### `email:deliver`
**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
// emdash-plugin.jsonc declares hooks.email-transport:register and network:request,
// with api.resend.com in allowedHosts.
"email:deliver": {
exclusive: true,
handler: async ({ message }, ctx) => {
const apiKey = await ctx.settings.get("apiKey");
await ctx.http!.fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
to: message.to,
cc: message.cc,
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
}),
});
},
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
### `email:afterSend`
**Requires:** `hooks.email-events:register` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
## Comment hooks
Comment hooks run for sandboxed and trusted plugins through the shared pipeline. All four require `users:read`. Their events include author email and request-derived information, so the pipeline skips the hook when the capability is absent.
### `comment:beforeCreate`
Runs before storage. Return a modified event to enrich the comment or its moderator-only metadata, return `false` to reject the comment, or return nothing to keep it unchanged.
```typescript
"comment:beforeCreate": async (event) => {
if (event.comment.body.includes("blocked phrase")) return false;
return {
...event,
metadata: { ...event.metadata, reviewedBy: "rules-v1" },
};
},
```
Event:
```typescript
{
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
}
metadata: Record<string, unknown>;
}
```
Returns: the event, `false`, or `void`.
### `comment:moderate`
The exclusive moderation provider decides the initial status after the enrichment pipeline.
```typescript
"comment:moderate": {
exclusive: true,
handler: async (event) => ({
status: event.priorApprovedCount > 0 ? "approved" : "pending",
reason: "First-time authors require review",
}),
},
```
The event contains `comment`, `metadata`, collection comment settings, and `priorApprovedCount`. Return `{ status: "approved" | "pending" | "spam", reason?: string }`.
### `comment:afterCreate`
Runs after the comment is stored. The event contains the stored comment, moderation metadata, the target content summary, and the content author when available. Use it for notifications and other side effects. Returns `void`.
### `comment:afterModerate`
Runs after an administrator or a plugin changes a comment's status. The event contains the stored comment, `previousStatus`, `newStatus`, the moderator's `{ id, name }`, and `origin`. Administrator changes use `{ source: "admin", userId }`; `ctx.comments.setStatus()` uses `{ source: "plugin", pluginId }`. A transition runs the hook once. Returns `void`.
## Cron Hook
### `cron`
Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`.
```typescript
hooks: {
"plugin:activate": async (_event, ctx) => {
await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
},
cron: async (event, ctx) => {
if (event.name === "daily-cleanup") {
// ... cleanup logic
}
},
},
```
Event: `{ name: string, data?: Record<string, unknown> }`
Returns: `void`
## Public Page Hooks
Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components.
### `page:metadata`
Contributes typed metadata to `<head>` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.
Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.
```typescript
"page:metadata": async (event, ctx) => {
if (event.page.kind !== "content") return null;
return [
{ kind: "meta", name: "author", content: "My Blog" },
{
kind: "jsonld",
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
graph: {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: event.page.pageTitle ?? event.page.title,
description: event.page.description,
},
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageMetadataContribution | PageMetadataContribution[] | null`
Contribution types:
- `{ kind: "meta", name: string, content: string, key?: string }` — `<meta name="..." content="...">`
- `{ kind: "property", property: string, content: string, key?: string }` — `<meta property="..." content="...">` (OpenGraph)
- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }` — `<link>` tag (HTTP/HTTPS URLs only)
- `{ kind: "jsonld", id?: string, graph: object | object[] }` — `<script type="application/ld+json">`
Dedupe rules: first contribution wins per key. Canonical is singleton.
### `page:fragments` (trusted only)
Contributes raw HTML, scripts, or markup to `head`, `body:start`, or `body:end`. The sandbox authoring type and manifest schema accept the declaration, and the plugin CLI warns that it is trusted-only. The sandbox host proxy excludes the hook, so a registry-installed plugin never contributes these fragments.
```typescript
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "head",
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
async: true,
},
{
kind: "html",
placement: "body:start",
html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageFragmentContribution | PageFragmentContribution[] | null`
Contribution types:
- `{ kind: "external-script", placement, src, async?, defer?, attributes?, key? }`
- `{ kind: "inline-script", placement, code, attributes?, key? }`
- `{ kind: "html", placement, html, key? }`
Placements: `"head"`, `"body:start"`, `"body:end"`
## Execution Order
1. Lower `priority` values run first
2. Equal priorities: plugin registration order
3. `dependencies` forces a hook to wait for the named plugins regardless of priority
These ordering rules apply to sandboxed and trusted hooks in the shared host pipeline.
## Error Handling
- `errorPolicy: "abort"` (default) — pipeline stops, operation may fail
- `errorPolicy: "continue"` — error logged, remaining hooks still run
These policies apply to sandboxed and trusted hooks in the shared host pipeline.
## Quick Reference
| Hook | Trigger | Capability Required | Return |
| ------------------------- | -------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `plugin:install` | First install | — | `void` |
| `plugin:activate` | Plugin enabled | — | `void` |
| `plugin:deactivate` | Plugin disabled | — | `void` |
| `plugin:uninstall` | Plugin removed | — | `void` |
| `content:beforeSave` | Before save | `content:write` | Modified content, `SandboxHookErrorEnvelope`, or `void` |
| `content:afterSave` | After save | `content:read` | `void` |
| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel |
| `content:afterDelete` | After delete | `content:read` | `void` |
| `content:beforePublish` | Before publish | `hooks.content-policy:register` | `void` or cancellation |
| `content:beforeSchedule` | Before schedule | `hooks.content-policy:register` | `void` or cancellation |
| `content:beforeUnpublish` | Before unpublish | `hooks.content-policy:register` | `void` or cancellation |
| `content:afterPublish` | After publish | `content:read` | `void` |
| `content:afterUnpublish` | After unpublish | `content:read` | `void` |
| `content:afterRestore` | After restore | `content:read` | `void` |
| `content:afterSchedule` | After schedule | `content:read` | `void` |
| `content:afterUnschedule` | After unschedule | `content:read` | `void` |
| `media:beforeUpload` | Before upload | `media:write` | Modified file info or `void` |
| `media:afterUpload` | After upload | `media:read` | `void` |
| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` |
| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) |
| `email:afterSend` | After email send | `hooks.email-events:register` | `void` |
| `comment:beforeCreate` | Before comment save | `users:read` | Modified event, `false`, or `void` |
| `comment:moderate` | Initial moderation | `users:read` | Moderation decision (exclusive) |
| `comment:afterCreate` | After comment save | `users:read` | `void` |
| `comment:afterModerate` | After status change | `users:read` | `void` |
| `cron` | Scheduled task fires | — | `void` |
| `page:metadata` | Page render | — | Metadata contributions |
| `page:fragments` | Page render | `hooks.page-fragments:register` (native only) | Fragment contributions |
@@ -0,0 +1,25 @@
# Media access
Keep media authority as narrow as the plugin's behavior permits.
| Capability | Access |
| ---------------------- | ---------------------------------------------------------- |
| `media:read` | Ready-media metadata and authenticated ID-based asset URLs |
| `media:bytes:read` | Bounded original bytes and content hashes |
| `media:metadata:write` | Alt text, caption, and complete focal-point updates |
| `media:write` | Upload and delete; implies `media:read` |
`media:read` metadata includes dimensions, alt text, caption, focal point, blurhash, dominant color, and folder ID. It excludes storage keys, author identity, content hashes, and bytes. Logged-out asset requests stop at authentication before the media lookup.
`ctx.media.readBytes(id, { maxBytes })` buffers from the configured storage adapter. The default limit is 10 MiB and callers cannot request more than 16 MiB. The host enforces the limit while consuming the stream rather than trusting stored size metadata.
`ctx.media.updateMetadata()` changes only alt text, caption, and a complete focal-point pair. It cannot upload, replace, move, or delete the underlying file.
With `media:write`, upload bytes through the bridge:
```typescript
const bytes = await source.arrayBuffer();
const uploaded = await ctx.media!.upload("report.pdf", "application/pdf", bytes);
```
Both sandbox runners write through the configured media adapter and create a ready record. A sandbox does not follow a presigned upload URL directly.
@@ -0,0 +1,253 @@
# Portable Text Block Types
Plugin CLI and registry packages cannot define Portable Text block types. `emdash-plugin build` warns that `portableTextBlocks` require trusted mode and omits them from the sandboxed manifest. Site-side rendering also needs an Astro `componentsEntry`, which is loaded with the site at build time.
Core can forward declarative block metadata from a config-declared standard plugin descriptor, but that path does not make the definition portable through the registry and does not provide a site renderer. Treat custom Portable Text blocks as a native-plugin feature unless the site owns both the descriptor and the rendering components and has verified the complete editor-to-render path.
Plugins can add custom block types to the Portable Text editor. These appear in the slash command menu and can be inserted into any `portableText` field.
## Declaring Block Types
In `definePlugin()`, declare blocks under `admin.portableTextBlocks`:
```typescript
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
{
type: "codepen",
label: "CodePen",
icon: "code",
placeholder: "Paste CodePen URL...",
},
],
}
```
### Block Config Fields
| Field | Type | Description |
| ------------- | -------- | ----------------------------------------------- |
| `type` | `string` | Block type name (used in PT `_type`). Required. |
| `label` | `string` | Display name in slash command menu. Required. |
| `icon` | `string` | Icon key. Optional. |
| `description` | `string` | Description in slash command menu. Optional. |
| `placeholder` | `string` | Input placeholder text. Optional. |
| `fields` | `array` | Block Kit form fields for editing UI. Optional. |
### Icons
Named icons: `video`, `code`, `link`, `link-external`. Unknown or missing falls back to a generic cube icon.
### Fields
When `fields` is declared, the editor renders a Block Kit form for editing. When omitted, a simple URL input is shown.
Fields use Block Kit element syntax:
```typescript
fields: [
{
type: "text_input",
action_id: "id",
label: "URL",
placeholder: "https://...",
},
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image" },
{ type: "number_input", action_id: "start", label: "Start Time (seconds)" },
{ type: "toggle", action_id: "autoplay", label: "Autoplay" },
{
type: "select",
action_id: "size",
label: "Size",
options: [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
],
},
];
```
See [Block Kit](./block-kit.md) for element shapes. The Portable Text editor additionally renders `repeater` and `media_picker` authoring elements. Do not assume every element accepted by the shared union renders in every Block Kit surface.
The `action_id` of each field becomes a key in the Portable Text block data. The field with `action_id: "id"` is treated as the primary identifier (typically the URL).
### Data Flow
1. User types `/` in the editor and selects a block type
2. Modal opens with Block Kit form (or simple URL input if no fields)
3. User fills in fields and submits
4. Block is inserted with `_type` set to the block type and field values as properties
5. Editing an existing block re-opens the modal pre-populated
Portable Text output:
```json
{
"_type": "youtube",
"_key": "abc123",
"id": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"poster": "https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg"
}
```
## Site-Side Rendering
To render block types on the site, export Astro components from a `componentsEntry`.
### Component File
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// This export name is required
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
```
### Astro Component
```astro
---
// src/astro/YouTube.astro
const { id, title, poster } = Astro.props.node;
// Extract video ID from URL
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
---
<div class="youtube-embed">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title || "YouTube Video"}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
```
Component receives `Astro.props.node` with the full block data.
### Plugin Descriptor
Set `componentsEntry` in the descriptor:
```typescript
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
version: "1.0.0",
options,
};
}
```
### Package Exports
Add the `./astro` export:
```json
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
}
}
```
### Auto-Wiring
Plugin block components are automatically merged into `<PortableText>` on the site. Merge order:
1. EmDash defaults (lowest priority)
2. Plugin block components
3. User-provided components (highest priority)
Site authors don't need to import anything. User components take precedence over plugin defaults.
## Complete Example
```typescript
// src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export function embedsPlugin(options = {}): PluginDescriptor {
return {
id: "embeds",
version: "1.0.0",
entrypoint: "@my-org/plugin-embeds",
componentsEntry: "@my-org/plugin-embeds/astro",
options,
};
}
export function createPlugin() {
return definePlugin({
id: "embeds",
version: "1.0.0",
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{
type: "text_input",
action_id: "poster",
label: "Poster Image URL",
},
],
},
{
type: "linkPreview",
label: "Link Preview",
icon: "link-external",
placeholder: "Paste any URL...",
},
],
},
});
}
export default createPlugin;
```
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import LinkPreview from "./LinkPreview.astro";
export const blockComponents = {
youtube: YouTube,
linkPreview: LinkPreview,
};
```
@@ -0,0 +1,101 @@
# Publishing sandboxed plugins
Use `@emdash-cms/plugin-cli` for registry publishing. The package profile and release records belong to the Atmosphere account named by `publisher` in `emdash-plugin.jsonc`.
## Required package metadata
Before publishing, require:
- `@emdash-cms/plugin-cli` installed in the plugin package;
- a unique plugin `slug`;
- a publisher DID or Atmosphere handle;
- a version in `package.json` or `emdash-plugin.jsonc`;
- a license, at least one author, and at least one security contact;
- declared capabilities and allowed hosts that match the plugin implementation;
- a canonical public GitHub `repo` for delegated releases.
Run validation from the plugin directory:
```sh
pnpm exec emdash-plugin validate
```
## Local publishing
Use local publishing when the maintainer starts the release from their computer:
```sh
pnpm exec emdash-plugin login alice.example.com
pnpm exec emdash-plugin publish
```
`publish` builds and validates the bundle, uploads its files to the publisher's personal data server, and creates the package release record. Use `emdash-plugin bundle` to inspect a tarball without publishing it.
The success output uses the registry identifier `@<publisher-handle>/<slug>`, prints the eventual plugin-page URL, and provides `emdash-plugin info <handle> <slug> --version <version> --watch`. Before approval, `info` reads the labeler's current checks and does not return unapproved package metadata from the aggregator.
## Automated repository releases
Generate the shared workflow from one plugin package, not the monorepo root. Pass `--dir <plugin-directory>` when running the command from elsewhere:
```sh
pnpm exec emdash-plugin release setup
```
The command prepares the current signed package profile and writes `.github/workflows/emdash-release.yml` at the Git repository root. If the manifest omits `repo`, setup detects a GitHub `origin` remote and pre-fills the repository prompt. It does not push the file. The workflow is shared by all plugin packages in that repository and requires no Actions secret.
When `.changeset/config.json` exists at the repository root, interactive setup offers **Follow Changesets releases**. The generated workflow accepts the Changesets Action published-package JSON and publishes packages that also contain `emdash-plugin.jsonc`.
Connect Changesets Action v2 by exposing its outputs from the existing release job and calling the generated workflow:
```yaml
jobs:
release:
# Keep the existing runner, permissions, and steps.
outputs:
published: ${{ steps.changesets.outputs.published }}
published-packages: ${{ steps.changesets.outputs['published-packages'] }}
publish-emdash-plugins:
needs: release
if: needs.release.outputs.published == 'true'
uses: ./.github/workflows/emdash-release.yml
with:
published-packages: ${{ needs.release.outputs['published-packages'] }}
permissions:
contents: read
id-token: write
attestations: write
```
For Changesets Action v1, set the normalized `published-packages` job output from `${{ steps.changesets.outputs.publishedPackages }}` instead. Private EmDash-only packages require both `privatePackages.version: true` and `privatePackages.tag: true`; add unrelated private packages to `ignore`.
Without Changesets, the generated workflow publishes tags in `<slug>@<version>` form:
```sh
git tag gallery@1.2.3
git push origin gallery@1.2.3
```
The workflow runs `release prepare` through the exact plugin CLI version that generated it. The command finds exactly one `emdash-plugin.jsonc` whose slug matches the tag, requires the manifest version to match, builds that package, and writes its outputs for the provenance and release Actions. Duplicate slugs, missing packages, and version mismatches stop before attestation.
The first run uses GitHub OpenID Connect to request a repository connection. The service checks that the initiating package's signed profile names the same repository before creating the request. The publisher approves the repository, workflow file, ref scope, and environment in the release dashboard. A manual run requests approval the first time its branch is used; confirmation adds that scope without removing approved tags or branches.
Prepare another package without changing the workflow. Changesets users add it to a changeset; package-tag users push its tag:
```sh
pnpm exec emdash-plugin profile setup --dir packages/comments
git tag comments@1.0.0
git push origin comments@1.0.0
```
`profile setup` confirms that it published the profile, then prints `emdash-plugin publish` for a manual release and `emdash-plugin release setup` for GitHub Actions.
The later package reuses an approved repository scope only when its signed profile names that repository. Package approvals created by older workflows remain package-scoped until an unmatched package or ref is explicitly approved as a repository connection. Release confirmation remains package-specific: `escalation-only` requires approval when declared access increases, while `always` requires approval for every release.
## Verification boundaries
The release service verifies the GitHub repository and owner IDs, workflow, ref, environment, commit, run, runner, package profile, bundle checksum, manifest identity, declared access, and raw Sigstore provenance. It accepts artifacts only after the repository workflow and signed package profile both authorize the run.
Metadata moderation and release verification are separate. Registry moderation covers displayed package metadata and media. Installation independently verifies release records, bundle checksums, and declared access.
Use the public [Automated plugin releases](https://docs.emdashcms.com/plugins/creating-plugins/delegated-releases/) guide for the complete publisher journey and troubleshooting.
@@ -0,0 +1,43 @@
# Sandbox boundaries
Registry plugins run against a capability-gated host API, not the complete trusted EmDash runtime. Design within exported authoring types and do not infer an API from internal repositories or admin routes.
## Transport limits
- `ctx.http.fetch()` preserves binary requests and responses across both runners, but complete bodies are buffered and limited to 8 MiB of decoded bytes.
- Declared plugin route bodies are buffered with a 1 MiB default and 8 MiB author maximum. Raw route responses are buffered to 8 MiB.
- Media byte reads default to 10 MiB and cannot request more than 16 MiB.
- Neither test host reproduces deployed CPU, memory, or subrequest limits.
## Trusted-only surfaces
- `page:fragments` is accepted by shared authoring types but excluded from sandbox registration. Use validated `page:metadata`; raw HTML, scripts, and styles require a trusted native plugin.
- React admin code, Astro render components, build-time integrations, host bindings, Node built-ins, TCP sockets, and direct database access require trusted site code.
- Custom Portable Text definitions and Astro renderers remain native-only. Registry packages can use the declarative admin surfaces that the CLI serializes.
## Content and schema limits
- Schema access is read-only. Plugins cannot create, alter, attach, or delete collection definitions through `ctx.schema`.
- Content writes do not expose permanent deletion.
- Publication policy hooks can reject publish, schedule, and unpublish, but cannot transform the action or restore content.
- Publication and restore actions require separate capabilities and opaque revisions.
- Public URL resolution never returns previews.
## Taxonomy, redirect, comment, and media limits
- Taxonomy writes cannot manage definitions, attachments, term updates or deletion, or replace all assignments.
- Redirect writes cannot set host-owned fields and remain subject to host validation.
- Comment administration excludes trashed comments and linked user-account IDs. It cannot hard-delete or bulk-replace statuses.
- Media metadata writes cannot upload, replace, move, or delete files. Byte reads and content hashes require separate authority.
## Routes and admin UI
- `public: true` removes host authentication. It does not create a restricted public view; validate requests and return the minimum data.
- Raw routes use `pluginResponse()`, an allowlisted set of representation/download/redirect headers, host-owned caching and security headers, and no active same-origin browser content. They cannot back MCP tools.
- Credential, cookie, Cloudflare Access, proxy authorization, and EmDash CSRF headers never cross declared route-header boundaries.
- Saved-entry panels and actions receive canonical saved identity and version. Ordinary panel load and typing never send unsaved state. An explicit interaction can receive only extension-selected draft fields with `admin.editor-draft:read`, and can propose an atomic whole-field patch with the independent `admin.editor-draft:patch` capability. The host validates and previews a patch but never saves it automatically. Use capability-gated APIs to read saved content.
- `routeCtx.ui` carries host-attested admin locale, direction, and surface. Manifest labels remain static strings; the host does not consume plugin translation catalogs.
## Runner-only methods are not portable
Methods absent from public authoring types are not part of the registry contract even if one wrapper contains them. Write against `SandboxedPlugin` and exported context types, then test the feature through both bridges when behavior is runner-sensitive.
@@ -0,0 +1,213 @@
# Storage and KV
Sandboxed plugins have three plugin-scoped data APIs:
| API | Use |
| -------------------------- | ------------------------------------------------------- |
| `ctx.storage.<collection>` | Queryable records declared in `emdash-plugin.jsonc` |
| `ctx.settings` | User-configurable settings, including encrypted secrets |
| `ctx.kv` | Cursors, cached values, and other key-value state |
All three stores use the host database and are isolated by runtime plugin ID. None needs a capability.
## Declare storage collections
Declare every collection and query index in the manifest:
```jsonc title="emdash-plugin.jsonc"
{
"storage": {
"submissions": {
"indexes": ["formId", "status", "createdAt", ["formId", "createdAt"]],
"uniqueIndexes": ["externalId"],
},
},
}
```
An undeclared collection is rejected by the sandbox bridge. Fields in `uniqueIndexes` are already queryable; do not repeat them in `indexes`.
## Collection operations
Every declared collection exposes this portable API in native, Cloudflare-sandboxed, and Node/workerd-sandboxed execution:
```typescript
interface StorageCollection<T = unknown> {
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
getVersioned(id: string): Promise<{ value: T; revision: string } | null>;
compareAndSet(
id: string,
expectedRevision: string | null,
data: T,
): Promise<{ applied: true; revision: string } | { applied: false }>;
compareAndDelete(id: string, expectedRevision: string): Promise<{ applied: boolean }>;
updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
query(options?: QueryOptions): Promise<{
items: Array<{ id: string; data: T }>;
cursor?: string;
hasMore: boolean;
}>;
count(where?: WhereClause): Promise<number>;
}
```
The Node/workerd wrapper also contains content batch methods. They are not part of `StorageCollection`; storage batch methods in the interface above are portable across both runners.
## Basic and batch writes
```typescript
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
await submissions.put("sub_123", {
formId: "contact",
status: "pending",
createdAt: new Date().toISOString(),
});
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
const items = await submissions.getMany(["sub_123", "sub_456"]);
await submissions.putMany([
{ id: "sub_456", data: { formId: "contact", status: "pending" } },
{ id: "sub_789", data: { formId: "sales", status: "pending" } },
]);
const deleted = await submissions.deleteMany(["sub_456", "sub_789"]);
```
`getMany()` returns a `Map`, including after crossing either sandbox bridge.
## Revision-based compare and set
Use `getVersioned()`, `compareAndSet()`, and `compareAndDelete()` when concurrent requests may replace the same whole value.
```typescript
const current = await submissions.getVersioned("sub_123");
if (!current) throw new Error("Submission not found");
const result = await submissions.compareAndSet("sub_123", current.revision, {
...current.value,
status: "processing",
});
if (!result.applied) {
// Another request changed or deleted the value. Read it again before retrying.
}
```
The operations have these preconditions:
| Operation | Behavior |
| ------------------------------------- | --------------------------------------------------------- |
| `getVersioned(key)` | Returns `{ value, revision }`, or `null` only when absent |
| `compareAndSet(key, null, value)` | Creates only when absent |
| `compareAndSet(key, revision, value)` | Replaces only when the current revision matches |
| `compareAndDelete(key, revision)` | Deletes only when the current revision matches |
A stored JSON `null` still returns a versioned envelope. Every successful write changes the revision, including an equal-value `put()` or `set()`. Revisions are opaque, key-specific values; pass them back unchanged.
Conflicts return `applied: false`. Invalid inputs, permission failures, unique-index violations, and database failures reject. After a conflict, re-read and recompute; keep retries bounded. A lost response can leave the write outcome unknown, so CAS is not an exactly-once mechanism for external side effects.
The versioned methods are also available on `ctx.kv`:
```typescript
const current = await ctx.kv.getVersioned<number>("state:completed");
const next = (current?.value ?? 0) + 1;
const result = await ctx.kv.compareAndSet("state:completed", current?.revision ?? null, next);
```
## Predicate-guarded updates
`updateIf()` changes fields of an existing document when its stored data matches a guard. The guard, field replacements, and integer deltas execute atomically for that record.
```typescript
const result = await submissions.updateIf("sub_123", {
where: { status: "pending", attempts: { lt: 3 } },
set: { status: "processing", lastAttemptAt: new Date().toISOString() },
delta: { attempts: { inc: 1 } },
});
if (result.applied) {
ctx.log.info("Claimed submission", { submission: result.data });
}
```
`updateIf()` returns `{ applied: false }` when the row is absent, the guard fails, the stored document is not an object, or integer arithmetic is unsafe. It never inserts.
Rules:
- `where` is required. An explicit `{}` means “match any existing row.”
- `set` replaces supplied top-level fields and leaves other fields unchanged.
- `delta` contains exactly one safe-integer `inc` or `dec` per field. Missing or `null` counters start at zero.
- A field cannot appear in both `set` and `delta`.
- At least one defined field must remain in `set` or `delta`.
- Pair `dec: n` with a `gte: n` guard when the value must stay nonnegative.
Malformed arguments reject without writing. In native PostgreSQL execution, serialization failures and deadlocks throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"` and `retryable: true`. Sandbox transports preserve the safe fields but do not guarantee `instanceof`; check `code` and `retryable`. Restart an entire explicit transaction before retrying it.
## Indexed queries
Only declared index fields can be filtered or ordered:
```typescript
const result = await submissions.query({
where: {
formId: "contact",
status: { in: ["pending", "processing"] },
createdAt: { gte: "2026-01-01" },
},
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
```
Supported filters are exact values, `{ in: [...] }`, `{ startsWith: "..." }`, and range objects using `gt`, `gte`, `lt`, or `lte`. A range needs at least one defined bound.
`query()` defaults to 50 items and returns at most 100 per page. Follow `cursor` while `hasMore` is true. `count(where)` accepts the same indexed filters.
Composite index order determines useful query shapes. `['formId', 'createdAt']` supports filtering by `formId` and ordering by `createdAt`; it does not replace a standalone `createdAt` index for queries that omit `formId`.
## KV operations
KV supports unconditional, versioned, delete, and prefix-list operations:
```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 }>>;
getVersioned<T>(key: string): Promise<{ value: T; revision: string } | null>;
compareAndSet(
key: string,
expectedRevision: string | null,
value: unknown,
): Promise<{ applied: true; revision: string } | { applied: false }>;
compareAndDelete(key: string, expectedRevision: string): Promise<{ applied: boolean }>;
}
```
Use stable prefixes to keep internal KV keys discoverable:
```typescript
await ctx.settings.set("webhookUrl", url);
await ctx.kv.set("state:lastRun", new Date().toISOString());
await ctx.kv.set("cache:summary", summary);
const settings = await ctx.settings.list();
```
The plugin CLI serializes `admin.settingsSchema`, and both sandbox bridges route `ctx.settings` through the same options records as the generated admin form. Values saved in that form are available through `ctx.settings.get("<key>")`. The complete settings API supports `set`, `delete`, `list`, `getVersioned`, `compareAndSet`, and `compareAndDelete`.
Fields declared as `secret` use a versioned AES-GCM envelope with the plugin ID and setting key as authenticated data. `EMDASH_ENCRYPTION_KEY` may contain a comma-separated rotation list: the first key encrypts new values and the envelope's `kid` selects a key for reads. Missing, wrong, and tampered keys fail closed without exposing plaintext. Existing plaintext secrets remain readable and become encrypted when saved again.
Keep the full encryption-key list with operational backups. Restoring the database without every key referenced by its encrypted settings leaves those values unreadable. `ctx.kv.get("settings:<key>")` remains a compatibility alias throughout EmDash 1.x; new plugins should use `ctx.settings`.
@@ -0,0 +1,19 @@
# Taxonomies and redirects
## Taxonomies
`taxonomies:read` exposes taxonomy definitions, terms, and entry assignments. `taxonomies:write` implies read and adds `createTerm()`, `addEntryTerms()`, and `removeEntryTerms()`.
Term assignment methods accept term row IDs or translation-group IDs, not slugs. Add and remove operations apply idempotent deltas rather than replacing the full set, so concurrent additions do not overwrite one another.
The host validates taxonomy attachment, entry and term ownership, configured locales, translation identity, and hierarchy. `createTerm()` rejects a parent for a flat taxonomy. Definition management, attachment changes, assignment replacement, term updates, and term deletion remain unavailable.
## Redirects
`redirects:read` exposes cursor-paged listing and versioned reads. `redirects:write` implies read and adds create, update, and delete.
Pass the opaque redirect `_rev` unchanged to update and delete. A stale revision returns `CONFLICT`; re-read before retrying. Redirect writes use the host's source-pattern, destination-parameter, duplicate-source, status, and loop validation. Plugin input cannot set host-owned fields such as the automatic-rule marker.
Changing redirect rules changes where visitors are sent, so request this capability only when the plugin owns that behavior.
For runtime tests, establish redirect state with fixtures, invoke the plugin through the real route/action boundary, and inspect persisted rules. Cover stale revisions and loop or destination validation when relevant.
@@ -0,0 +1,180 @@
# Editing Flow
How content editing works through the CLI. Covers Portable Text conversion, `_rev` tokens, and raw mode.
## Portable Text and Markdown
EmDash stores rich text as [Portable Text](https://portabletext.org/) (PT) — a structured JSON format. The CLI automatically converts between PT and markdown so you work with a familiar text format.
### Automatic Conversion
- **On read**: PT arrays in `portableText` fields are converted to markdown strings
- **On write**: markdown strings in `portableText` fields are converted back to PT arrays
- **Non-PT fields** (string, text, number, etc.) pass through unchanged
The CLI detects which fields need conversion by fetching the collection's field schema.
### Supported Markdown Syntax
Standard blocks (lossless round-trip):
| Markdown | PT block |
| ---------------------------- | ------------------------------------------ |
| `# Heading` through `######` | h1-h6 blocks |
| Plain paragraph | normal block |
| `> Quote` | blockquote |
| `- item` / `* item` | bullet list (nesting via 2-space indent) |
| `1. item` | numbered list (nesting via 2-space indent) |
| ` ``` ```lang``` ` | code block with language |
| `![alt](url)` | image block |
Inline marks:
| Markdown | PT mark |
| ------------- | --------------- |
| `**bold**` | `strong` |
| `_italic_` | `em` |
| `` `code` `` | `code` |
| `~~strike~~` | `strikethrough` |
| `[text](url)` | link annotation |
### Unknown Blocks (Opaque Fences)
Blocks the converter doesn't recognize (custom blocks, embeds, etc.) are serialized as HTML comments:
```markdown
<!--ec:block {"_type":"callout","level":"warning","text":"Be careful"} -->
```
These survive round-trips intact. You can see and move them, but editing the JSON risks corruption. On write, they're deserialized back to the original PT block.
### Raw Mode
Skip markdown conversion entirely to work with raw PT JSON:
```bash
npx emdash content get posts 01ABC123 --raw
```
Use raw mode when:
- You need exact control over PT structure
- You're working with custom block types
- You're copying PT between items without transformation
### Writing Content
When creating or updating content, each field is checked:
- `portableText` field + **string value** → converts markdown to PT before sending
- `portableText` field + **array value** → sends as raw PT (no conversion)
- Any other field type → sends as-is
```bash
# Markdown string — converted to PT automatically
npx emdash content create posts --data '{"title": "Hello", "body": "# Welcome\n\nThis is **bold**."}'
# Raw PT array — passed through as-is
npx emdash content create posts --data '{"title": "Hello", "body": [{"_type": "block", "children": [{"_type": "span", "text": "Welcome"}]}]}'
```
## Auto-Publishing
The CLI is designed for agents. It auto-publishes on `create` and `update` by default so agents get read-after-write consistency without managing the draft/publish lifecycle.
### How It Works
- **`create`** — creates the item, then publishes it. The returned item is in `published` status.
- **`update`** — updates the item. If the collection uses revisions and the update created a draft revision, it auto-publishes to promote the draft to the content table. The returned item reflects the updated data.
- **`get`** — returns the latest state. If a pending draft exists (e.g. someone edited in the admin UI but didn't publish), the draft data is returned instead of the published data. Use `--published` to see only published data.
Use `--draft` on create/update to skip auto-publishing.
### Why Auto-Publish?
EmDash collections can support draft revisions. When they do, `update` writes data to a draft revision instead of the content table. Without auto-publish, an agent would update, then `get` the item, and see stale published data — not the changes it just made. Auto-publish eliminates this confusion.
## Read-Before-Write
Updates use `_rev` tokens for optimistic concurrency — the same principle as a file editing tool that requires you to read a file before you can edit it. You must see what you're overwriting.
### The Analogy
Think of it like a filesystem edit tool:
1. You **read** the file to see its current contents
2. You decide what to change
3. You **write** with a reference to the version you read
If someone else changed the file between your read and your write, the write fails — you can't overwrite changes you haven't seen. The `_rev` token is your proof that you've seen the current state.
### How It Works
1. `content get` returns the item with a `_rev` token in the output
2. You pass that `_rev` back to `content update` via `--rev`
3. The server checks: if the item has changed since your read, it returns **409 Conflict**
4. A successful update returns a new `_rev` for subsequent edits
### What Is a `_rev` Token?
An opaque base64 string. Don't parse it — just pass it back.
### CLI Workflow
The CLI **requires** `--rev` on updates. The typical workflow:
```bash
# 1. Read the item — note the _rev in the output
npx emdash content get posts 01ABC123
# Output includes: _rev: MToyMDI2LTAyLTE0...
# 2. Update with the _rev you received — auto-publishes by default
npx emdash content update posts 01ABC123 \
--rev MToyMDI2LTAyLTE0... \
--data '{"title": "New Title"}'
# Output shows updated item with new _rev
```
If you try to update without `--rev`, the CLI rejects the command. This ensures you always know what you're overwriting.
### Conflict Handling
If someone else updated the item between your read and write:
```
EmDashApiError: Content has been modified since last read (version conflict)
status: 409
code: CONFLICT
```
Resolution: re-read with `get`, inspect the new state, then `update` with the fresh `_rev`.
### Locked Entries
A 409 does not always mean the item changed. If someone has the entry open in the admin, the write is refused with a different code:
```
EmDashApiError: Ada is holding this entry
status: 409
code: ENTRY_LOCKED
```
Re-reading does not clear this one. The item has not changed, so a fresh `_rev` produces the same refusal. Check `code` before you retry. Either wait, or pass `--override-lock` to write anyway. An editor releases the lock when they close the entry, and a lock left behind by a crashed tab lapses seven minutes after their last heartbeat.
Overriding does not take the lock. The editor keeps it and their next save is rejected as a version conflict, so wait unless you know they have gone.
`--override-lock` is accepted by `content update`, `content delete`, `content publish`, `content unpublish` and `content schedule`. Collections with edit locking switched off never return `ENTRY_LOCKED`.
### Which Operations Need `_rev`?
Only `update`. All other operations are either idempotent or non-destructive:
| Command | `--rev` needed? | Why |
| ------------------- | --------------- | ------------------------ |
| `content create` | No | Nothing exists yet |
| `content update` | **Yes** | Overwrites existing data |
| `content delete` | No | Soft delete, reversible |
| `content publish` | No | Idempotent status change |
| `content unpublish` | No | Idempotent status change |
| `content schedule` | No | Only changes metadata |
| `content restore` | No | Restores from trash |
+267
View File
@@ -0,0 +1,267 @@
---
name: emdash-cli
description: Use the EmDash CLI to inspect and manage an EmDash instance from the command line, including content, schema, media, taxonomies, menus, search, authentication, seeds, migrations, generated types, and whole-site export and import.
---
# EmDash CLI
The EmDash CLI (`emdash`, with the short alias `em`) manages EmDash CMS instances. Commands fall into two categories:
- **Local commands** work with project files or a configured database: `init`, `doctor`, `seed`, `migrate`, `export-seed`, and `secrets`.
- **Remote commands** talk to a running EmDash instance: `types`, `login`, `logout`, `whoami`, `content`, `schema`, `media`, `search`, `taxonomy`, `menu`, `site`, and `plugin`.
Run `npx emdash --help` and `npx emdash <command> --help` for the installed version's exact commands and flags. Resolve the current target with a read command before a destructive or bulk mutation; examples in this skill do not authorize changing an instance the user did not place in scope.
## Authentication
Remote commands resolve auth automatically:
1. `--token` flag
2. `EMDASH_TOKEN` env var
3. Stored credentials from `emdash login`
4. Dev bypass (localhost only — no token needed)
For a localhost development server with the development bypass enabled, the client can authenticate automatically. For a remote instance, run `emdash login --url https://my-site.pages.dev` or provide a scoped token.
## Custom Headers & Reverse Proxies
Sites behind Cloudflare Access or other reverse proxies need auth headers on every request. The CLI supports this via `--header` flags and environment variables.
### Service tokens for automation
```bash
# Provide sensitive headers through the environment in CI.
export EMDASH_HEADERS="CF-Access-Client-Id: xxx
CF-Access-Client-Secret: yyy"
npx emdash whoami --url https://my-site.pages.dev
```
`emdash login --header` persists custom headers to `~/.config/emdash/auth.json` for later commands. Prefer environment-provided headers in CI so a service secret is not written to the credential file or shell history.
### Cloudflare Access Browser Flow
If you don't have service tokens and `cloudflared` is installed, the CLI will automatically:
1. Detect when Access blocks the request
2. Try to get a cached JWT via `cloudflared access token`
3. Fall back to `cloudflared access login` for browser-based auth
This works for interactive use but isn't suitable for CI. Use service tokens for automation.
### Generic Reverse Proxy Auth
The `--header` flag works with any auth scheme:
```bash
# Basic auth
npx emdash login --url https://example.com -H "Authorization: Basic dXNlcjpwYXNz"
# Custom auth header
npx emdash login --url https://example.com -H "X-API-Key: secret123"
```
## Quick Reference
### Database Setup
For normal site startup, use the project's package script. The first request runs pending migrations and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates `emdash-env.d.ts` when the server starts.
```bash
# Start the site with its package script
pnpm dev
# Export an existing database as a seed file
# (the runtime auto-discovers .emdash/seed.json on first boot;
# `mkdir -p` because the directory may not exist yet)
mkdir -p .emdash
npx emdash export-seed > .emdash/seed.json
npx emdash export-seed --with-content=all > .emdash/seed.json
```
### Type Generation
```bash
# Generate types from local dev server
npx emdash types
# Generate from remote
npx emdash types --url https://my-site.pages.dev
# Custom output path
npx emdash types --output src/types/cms.ts
```
Writes `.emdash/types.ts` (TypeScript interfaces) and `.emdash/schema.json`.
### Authentication
```bash
# Login (OAuth Device Flow)
npx emdash login --url https://my-site.pages.dev
# Check current user
npx emdash whoami
# Logout
npx emdash logout
# Generate an encryption key for deployment
npx emdash secrets generate
```
### Content CRUD
The CLI is designed for agents. Create and update auto-publish by default so agents get read-after-write consistency without managing drafts.
```bash
# List content
npx emdash content list posts
npx emdash content list posts --status published --limit 10
# Get a single item (Portable Text fields converted to markdown)
# Returns draft data if a pending draft exists
npx emdash content get posts 01ABC123
npx emdash content get posts 01ABC123 --raw # skip PT->markdown conversion
npx emdash content get posts 01ABC123 --published # ignore pending drafts
# Create content (auto-publishes by default)
npx emdash content create posts --data '{"title": "Hello", "body": "# World"}'
npx emdash content create posts --file post.json --slug hello-world
npx emdash content create posts --draft --data '...' # keep as draft
cat post.json | npx emdash content create posts --stdin
# Update (requires --rev from a prior get, auto-publishes by default)
npx emdash content update posts 01ABC123 --rev MToyMDI2... --data '{"title": "Updated"}'
npx emdash content update posts 01ABC123 --rev MToyMDI2... --draft --data '...' # keep as draft
# Delete (soft delete)
npx emdash content delete posts 01ABC123
# Lifecycle
npx emdash content publish posts 01ABC123
npx emdash content unpublish posts 01ABC123
npx emdash content schedule posts 01ABC123 --at 2026-03-01T09:00:00Z
npx emdash content restore posts 01ABC123
```
### Schema Management
```bash
# List collections
npx emdash schema list
# Get collection with fields
npx emdash schema get posts
# Create collection
npx emdash schema create articles --label Articles --description "Blog articles"
# Delete a collection after inspecting it and confirming the target
npx emdash schema get articles
npx emdash schema delete articles
# Add field
npx emdash schema add-field posts body --type portableText --label "Body Content"
npx emdash schema add-field posts featured --type boolean --required
# Remove field
npx emdash schema remove-field posts featured
```
`schema add-field` supports the field types printed by `npx emdash schema add-field --help`. The full product schema supports additional field types that are not necessarily creatable through this command.
### Media
```bash
# List media
npx emdash media list
npx emdash media list --mime image/png
# Upload
npx emdash media upload ./photo.jpg --alt "A sunset" --caption "Bristol, 2026"
# Get / delete
npx emdash media get 01MEDIA123
npx emdash media delete 01MEDIA123
```
### Search
```bash
npx emdash search "hello world"
npx emdash search "hello" --collection posts --limit 5
```
### Taxonomies
```bash
npx emdash taxonomy list
npx emdash taxonomy terms categories
npx emdash taxonomy add-term categories --name "Tech" --slug tech
npx emdash taxonomy add-term categories --name "Frontend" --parent 01PARENT123
```
### Menus
```bash
npx emdash menu list
npx emdash menu get primary
```
### Site Export and Import
`emdash site` copies a whole site (content model, content, history, settings, and media, but no users or secrets) into a `.emdash` site package, and imports a package into an empty site. The token needs `admin`, which the `emdash login` token has, or the `transfer:export`, `transfer:analyze`, and `transfer:execute` scopes. An `INSUFFICIENT_SCOPE` error means the token has neither. A package holds every entry and the email addresses of authors and commenters: treat it like a database backup.
```bash
# Export (re-run the same command to resume after an interruption)
npx emdash site export --url https://old.example.com --output site.emdash
npx emdash site export --url https://old.example.com --output site.emdash --no-comments
# Import, step 1: upload and analyze; prints the plan and its digest
npx emdash site import site.emdash --url https://new.example.com --analyze
npx emdash site import site.emdash --url https://new.example.com --analyze \
--map-principal editor@example.com=editor@example.com --use-target-title
# Import, step 2: execute exactly the reviewed plan
npx emdash site import site.emdash --url https://new.example.com --plan sha256:<hex> --confirm
# Follow up on an import by operation id
npx emdash site import status <operation-id> --url https://new.example.com
npx emdash site import resume <operation-id> [site.emdash] --url https://new.example.com
npx emdash site import receipt <operation-id> --url https://new.example.com
# Stop an import, or lift the write block a failed or cancelled import leaves
# (neither deletes what the import wrote; --yes skips the prompt)
npx emdash site import cancel <operation-id> --url https://new.example.com
npx emdash site import abandon <operation-id> --url https://new.example.com
```
Show the user the plan (differences from the source site, warnings, blockers, principal mappings) and get their confirmation before running `--confirm`; it writes to the target site and blocks other writes there until it finishes. `site import` exits `2` when the plan has blockers, and `site import status` exits `1` for an import that failed, was cancelled or abandoned, or expired. Only cancel or abandon an import when the user asks: abandoning leaves partial data on the site, which then has to be reset before another import. `--map-principal` takes `<principal id or email>=<user id, email, or none>` and is repeatable; decision flags only work with `--analyze`, and each change produces a new plan digest. The public site is not hidden during an import, so the target should stay private until the command prints a receipt with `receiptDigestValid: true`.
## Drafts and Publishing
The CLI auto-publishes on `create` and `update` by default. This means:
- **`create`** creates the item and immediately publishes it
- **`update`** updates the item and publishes if a draft revision was created
- **`get`** returns draft data if a pending draft exists (e.g. from the admin UI)
Use `--draft` on create/update to skip auto-publishing. Use `--published` on get to ignore pending drafts.
Collections that support revisions store edits as draft revisions. The CLI handles this transparently — agents don't need to know whether a collection uses revisions or not.
## JSON Output
All remote commands support `--json` for machine-readable output. It's auto-enabled when stdout is piped. `emdash site` always writes progress to stderr, so stdout holds only the JSON result; errors are `{ "error": { "code", "message" } }`.
```bash
# Pipe to jq
npx emdash content list posts --json | jq '.items[].slug'
# Use in scripts
ID=$(npx emdash content create posts --data '{"title":"Hello"}' --json | jq -r '.id')
```
## Editing Flow
For details on how content editing works — Portable Text/markdown conversion, `_rev` tokens, and raw mode — see **[EDITING-FLOW.md](./EDITING-FLOW.md)**.
+1
View File
@@ -0,0 +1 @@
../.agents/skills
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
node_modules
dist
.astro
.emdash/migrations.json
uploads
data.db
.wrangler
# Environment / Wrangler secrets
.env
.env.*
!.env.example
.dev.vars
.dev.vars.*
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"servers": {
"emdash-docs": {
"type": "http",
"url": "https://docs.emdashcms.com/mcp"
}
}
}
+107
View File
@@ -0,0 +1,107 @@
This is an EmDash site -- a CMS built on Astro with a full admin UI.
## Commands
```bash
pnpm dev # Start the Astro dev server
npx emdash types # Regenerate TypeScript types from a running site
```
The admin UI is at `http://localhost:4321/_emdash/admin`.
## Key Files
| File | Purpose |
| ------------------------ | ---------------------------------------------------------------------------------- |
| `astro.config.mjs` | Astro config with `emdash()` integration, database, and storage |
| `src/live.config.ts` | EmDash loader registration (boilerplate -- don't modify) |
| `seed/seed.json` | Schema definition + demo content (collections, fields, taxonomies, menus, widgets) |
| `emdash-env.d.ts` | Generated types for collections (auto-regenerated on dev server start) |
| `src/layouts/Base.astro` | Base layout with EmDash wiring (menus, search, page contributions) |
| `src/pages/` | Astro pages -- all server-rendered |
## Skills
Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
- **building-emdash-site** -- Querying content, rendering Portable Text, schema design, seed files, site features (menus, widgets, search, SEO, comments, bylines). Start here.
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
## Documentation
The EmDash docs are available as an MCP server at `https://docs.emdashcms.com/mcp`. When you need to verify an API, hook, config option, field type, or pattern, call `search_docs` against the live documentation rather than relying on training-data recall. The docs reflect current behaviour; assumptions may not.
This template ships with `.mcp.json`, `.cursor/mcp.json`, and `.vscode/mcp.json` so Claude Code, Cursor, and VS Code auto-discover the docs server. Other tools (OpenCode, Windsurf, etc.) need a manual one-time setup -- see [docs.emdashcms.com/docs-mcp](https://docs.emdashcms.com/docs-mcp).
## Rules
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
- Image fields are objects (`{ src, alt }`), not strings. Use `<Image image={...} />` from `"emdash/ui"`.
- `entry.id` is the slug (for URLs). `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
- When Astro's cache is enabled, pass content-query hints to `Astro.cache.set(cacheHint)`. Use the `WithCacheHint` variants for site settings, menus, taxonomies, and widget areas rendered by cached routes.
- Taxonomy names in queries must match the seed's `"name"` field exactly (e.g., `"category"` not `"categories"`).
## This Template
A blog with posts, pages, categories, tags, full-text search, and RSS. Designed for personal writing, technical writing, indie newsletters, and anything where the writing is the product. Editorial-tech aesthetic: confident sans-serif, restrained accent, real article structure with bylines and reading time.
## Pages
| Page | Path | What it shows |
| ----------- | ------------------ | ------------------------------------------------------------------------------------------------------ |
| Home | `/` | Featured post hero (large image + excerpt), latest posts grid |
| All posts | `/posts` | Article count, full post list with excerpts and tag chips |
| Post detail | `/posts/[slug]` | Featured image, title, body, left meta column (authors + date), right TOC + search + categories gutter |
| Search | `/search` | Full-text search UI |
| Page | `/pages/[slug]` | Static page content (Portable Text) |
| Category | `/category/[slug]` | Posts filtered by category |
| Tag | `/tag/[slug]` | Posts filtered by tag |
| RSS | `/rss.xml` | Generated feed |
## Schema
- `posts` collection: `title`, `featured_image`, `content` (Portable Text), `excerpt` (text).
- `pages` collection: `title`, `content` (Portable Text). Used for `/about` etc.
- Taxonomies: `category`, `tag`.
- Single `primary` menu (Home, About, Posts by default).
Site settings have `title` and `tagline` -- both render in the header / footer.
## Visual character
Single typeface: **Inter** on `--font-body`, used for everything including headings (`--font-heading` defaults to the body face; tighter letter-spacing on h1/h2). **JetBrains Mono** on `--font-mono` for inline code and code blocks. Body and headings share the same family; weight and size carry the hierarchy (`--font-weight-heading` 600, `--font-weight-display` 700 for h1/page titles).
The brand colour is `#0066cc` (`--color-brand`) -- used for links, the post-card title hover, and the search input focus ring. There's also a secondary text colour (`--color-text-secondary`) and a `--color-muted` for meta info. Don't add a second accent.
The article layout is the standout feature: a three-column reading view with a left meta column (author bylines, date), centred 680px body column, and a right gutter for search, table of contents, and categories. Don't flatten that into one column on desktop -- the layout signals "this is something to read".
## Customisation
Design tokens live in `src/styles/tokens.css` with their default values. To restyle the site, override tokens in `src/styles/theme.css` -- declarations there are unlayered, so they always beat the `@layer base` defaults. Don't edit `tokens.css` or `Base.astro` for visual changes.
Colours are defined with `light-dark(<light>, <dark>)`, so each token carries both modes. Overriding with a plain colour changes light and dark at once; use `light-dark()` in the override to keep them distinct. There is no separate dark palette to maintain.
Webfonts are configured in `astro.config.mjs` under `fonts:`. To swap the body face, change the `name:` for the entry bound to `cssVariable: "--font-body"`. Good alternatives: Geist, IBM Plex Sans, Söhne (if you have a licence), Public Sans. If you want a serif-bodied blog, swap to a humanist serif like Source Serif, Crimson Pro, or Lora -- but then also raise `--font-size-base` to `1.0625rem` for readability. To give headings their own face (or use a system font) without touching the font pipeline, override `--font-heading` or `--font-body` in `theme.css`.
CSS variables worth knowing (see `tokens.css` for the full list):
- `--color-brand`, `--color-brand-hover`, `--color-on-brand`, `--color-brand-ring`
- `--color-bg`, `--color-bg-subtle`, `--color-surface`, `--color-text`, `--color-text-secondary`, `--color-muted`, `--color-border`, `--color-border-subtle`
- `--font-body`, `--font-heading`, `--font-mono`
- `--font-weight-heading` (600) / `--font-weight-display` (700) -- heading weights; lower them if you switch to a serif
- `--tracking-tight` / `--tracking-snug` / `--tracking-wide` / `--tracking-wider` -- letter-spacing tokens used across headings and meta labels
- `--content-width` (680px) -- article body column
- `--wide-width` (1200px) -- max container
- `--gutter-width` (200px) -- right sidebar (TOC) on article pages
- `--meta-col-width` (180px) -- left meta column on article pages
- `--avatar-size-{xs,sm,md,lg}` -- byline avatar sizes at different scales
## What not to do
- Don't add a second accent colour or coloured section backgrounds. The page should be black, white, and one blue.
- Don't replace Inter with a display sans (Bebas, Anton, etc.). Headings rely on weight contrast, not novelty faces.
- Don't collapse the article gutter on desktop -- it's part of the reading experience.
- Don't use stock blog copy ("Welcome to my blog", "Stay tuned for more"). Write a real tagline that says what this blog is about.
- Don't seed the home page with three identical placeholder posts. If you only have one real post, show one real post.
- Comments are enabled on posts and rendered on the post detail page. Configure moderation before publishing the site, or remove `commentsEnabled` and the comments UI together.
+68
View File
@@ -0,0 +1,68 @@
# EmDash Blog Template (Cloudflare)
A clean, minimal blog built with [EmDash](https://github.com/emdash-cms/emdash) and deployed on Cloudflare Workers with D1 and R2.
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/emdash-cms/templates/tree/main/blog-cloudflare)
![Blog template homepage](https://raw.githubusercontent.com/emdash-cms/emdash/main/assets/templates/blog/latest/homepage-light-desktop.jpg)
## What's Included
- Featured post hero on the homepage
- Post archive with reading time estimates
- Category and tag archives
- Full-text search
- RSS feed
- SEO metadata and JSON-LD
- Dark/light mode
## Pages
| Page | Route |
|---|---|
| Homepage | `/` |
| All posts | `/posts` |
| Single post | `/posts/:slug` |
| Category archive | `/category/:slug` |
| Tag archive | `/tag/:slug` |
| Search | `/search` |
| Static pages | `/pages/:slug` |
| 404 | fallback |
## Screenshots
| | Desktop | Mobile |
|---|---|---|
| Light | ![homepage light desktop](https://raw.githubusercontent.com/emdash-cms/emdash/main/assets/templates/blog/latest/homepage-light-desktop.jpg) | ![homepage light mobile](https://raw.githubusercontent.com/emdash-cms/emdash/main/assets/templates/blog/latest/homepage-light-mobile.jpg) |
| Dark | ![homepage dark desktop](https://raw.githubusercontent.com/emdash-cms/emdash/main/assets/templates/blog/latest/homepage-dark-desktop.jpg) | ![homepage dark mobile](https://raw.githubusercontent.com/emdash-cms/emdash/main/assets/templates/blog/latest/homepage-dark-mobile.jpg) |
## Infrastructure
- **Runtime:** Cloudflare Workers
- **Database:** D1
- **Storage:** R2
- **Framework:** Astro with `@astrojs/cloudflare`
## Local Development
```bash
pnpm install
pnpm dev
```
Open http://localhost:4321/_emdash/admin and complete the setup wizard. EmDash runs database migrations and applies the blog seed during setup. The site is available at http://localhost:4321.
## Deploying
```bash
pnpm wrangler login
pnpm deploy
```
The first deployment provisions the named D1 database and R2 bucket from `wrangler.jsonc`. See [Deploy to Cloudflare](https://docs.emdashcms.com/deployment/cloudflare/) for production setup, or use the deploy button above.
## See Also
- [Node.js variant](../blog) -- same template using SQLite and local file storage
- [All templates](../)
- [EmDash documentation](https://docs.emdashcms.com/)
+38
View File
@@ -0,0 +1,38 @@
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { d1, r2 } from "@emdash-cms/cloudflare";
import { defineConfig, fontProviders } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
database: d1({ binding: "DB", session: "auto" }),
storage: r2({ binding: "MEDIA" }),
}),
],
fonts: [
{
provider: fontProviders.google(),
name: "Inter",
cssVariable: "--font-body",
weights: [400, 500, 600, 700],
fallbacks: ["sans-serif"],
},
{
provider: fontProviders.google(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 500],
fallbacks: ["monospace"],
},
],
devToolbar: { enabled: false },
});
+43
View File
@@ -0,0 +1,43 @@
// Generated by EmDash on dev server start
// Do not edit manually
/// <reference types="emdash/locals" />
import type { BylineSummary, ContentBylineCredit, TaxonomyTerm, PortableTextBlock } from "emdash";
export interface Page {
id: string;
slug: string | null;
status: string;
title: string;
content?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
byline?: BylineSummary | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}
export interface Post {
id: string;
slug: string | null;
status: string;
title: string;
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number; filename?: string; mimeType?: string; blurhash?: string; dominantColor?: string; focalX?: number; focalY?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown>; darkVariant?: { id: string; src?: string; alt?: string; width?: number; height?: number; filename?: string; mimeType?: string; blurhash?: string; dominantColor?: string; focalX?: number; focalY?: number; provider?: string; previewUrl?: string; meta?: Record<string, unknown> } };
content?: PortableTextBlock[];
excerpt?: string;
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
byline?: BylineSummary | null;
bylines?: ContentBylineCredit[];
terms?: Record<string, TaxonomyTerm[]>;
}
declare module "emdash" {
interface EmDashCollections {
pages: Page;
posts: Post;
}
}
+32
View File
@@ -0,0 +1,32 @@
{
"name": "potluck",
"version": "0.0.3",
"private": true,
"type": "module",
"emdash": {
"label": "Blog",
"seed": "seed/seed.json"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"deploy": "astro build && wrangler deploy",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "^14.3.1",
"@astrojs/react": "^6.0.5",
"@emdash-cms/cloudflare": "^1.0.1",
"astro": "^7.3.2",
"emdash": "^1.0.1",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@astrojs/check": "^0.9.10",
"@cloudflare/workers-types": "^5.20260820.1",
"wrangler": "^4.125.0"
},
"packageManager": "pnpm@11.9.0+sha512.bd682d5d03fe525ef7c9fd6780c6884d1e756ac4c9c9fe00c538782824310dcf90e3ddc4f53835f06dfaebd5085e41855e0bcbb3b60de2ac5bbab89e5036f03b"
}
+8106
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
# Build scripts: allow only what each runtime needs; rest false so
# strictDepBuilds (pnpm >=10.26 / 11) passes.
allowBuilds:
esbuild: true
workerd: true
sharp: false
# Supply-chain hardening: cooldown on brand-new releases (EmDash exempt)
# and no non-registry transitive sources. (No trustPolicy: no-downgrade --
# it trips on upstream provenance regressions we don't control.)
minimumReleaseAge: 1440
minimumReleaseAgeExclude:
- emdash
- "@emdash-cms/*"
blockExoticSubdeps: true
+940
View File
@@ -0,0 +1,940 @@
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Blog Starter",
"description": "A blog with posts and pages",
"author": "EmDash"
},
"settings": {
"title": "My Blog",
"tagline": "Thoughts on building for the web"
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"urlPattern": "/posts/{slug}",
"supports": ["drafts", "revisions", "preview", "scheduling", "search", "seo"],
"commentsEnabled": true,
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "featured_image",
"label": "Featured Image",
"type": "image"
},
{
"slug": "content",
"label": "Content",
"type": "portableText",
"searchable": true
},
{
"slug": "excerpt",
"label": "Excerpt",
"type": "text"
}
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"urlPattern": "/pages/{slug}",
"supports": ["drafts", "revisions", "preview", "search", "seo"],
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "content",
"label": "Content",
"type": "portableText",
"searchable": true
}
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{
"slug": "development",
"label": "Development"
},
{
"slug": "design",
"label": "Design"
},
{
"slug": "notes",
"label": "Notes"
}
]
},
{
"name": "tag",
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["posts"],
"terms": [
{
"slug": "webdev",
"label": "Web Development"
},
{
"slug": "opinion",
"label": "Opinion"
},
{
"slug": "tools",
"label": "Tools"
},
{
"slug": "creativity",
"label": "Creativity"
}
]
}
],
"bylines": [
{
"id": "byline-editorial",
"slug": "emdash-editorial",
"displayName": "EmDash Editorial"
},
{
"id": "byline-guest",
"slug": "guest-contributor",
"displayName": "Guest Contributor",
"isGuest": true
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{
"type": "custom",
"label": "Home",
"url": "/"
},
{
"type": "custom",
"label": "About",
"url": "/pages/about"
},
{
"type": "custom",
"label": "Posts",
"url": "/posts"
}
]
}
],
"widgetAreas": [
{
"name": "sidebar",
"label": "Sidebar",
"description": "Widget area displayed on single post pages",
"widgets": [
{
"type": "component",
"componentId": "core:search",
"title": "Search"
},
{
"type": "component",
"componentId": "core:categories",
"title": "Categories"
},
{
"type": "component",
"componentId": "core:tags",
"title": "Tags"
},
{
"type": "component",
"componentId": "core:recent-posts",
"title": "Recent Posts",
"settings": {
"count": 5,
"showDate": true
}
},
{
"type": "component",
"componentId": "core:archives",
"title": "Archives",
"settings": {
"type": "monthly",
"limit": 6
}
}
]
},
{
"name": "footer",
"label": "Footer",
"description": "Widget area displayed in the site footer",
"widgets": [
{
"type": "content",
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A blog about software, design, and the occasional stray thought.",
"_key": "k1"
}
],
"_key": "k0"
}
]
}
]
}
],
"sections": [
{
"slug": "newsletter-signup",
"title": "Newsletter Signup",
"description": "A call-to-action block for newsletter subscriptions",
"keywords": ["newsletter", "subscribe", "email", "cta"],
"source": "theme",
"content": [
{
"_type": "block",
"style": "h3",
"children": [
{
"_type": "span",
"text": "Stay in the loop",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Get notified when new posts are published. No spam, unsubscribe anytime.",
"_key": "k3"
}
],
"_key": "k2"
}
]
},
{
"slug": "about-author",
"title": "About the Author",
"description": "Brief author bio for use in posts or pages",
"keywords": ["author", "bio", "about"],
"source": "theme",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet.",
"_key": "k1"
}
],
"_key": "k0"
}
]
}
],
"content": {
"pages": [
{
"id": "about",
"slug": "about",
"status": "published",
"data": {
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Built with Astro and EmDash. The source is open if you want to see how it works.",
"_key": "k3"
}
],
"_key": "k2"
}
]
}
}
],
"posts": [
{
"id": "post-1",
"slug": "building-for-the-long-term",
"status": "published",
"data": {
"title": "Building for the Long Term",
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
"alt": "Code on a monitor in a dark room",
"filename": "building-long-term.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "What survives",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality.",
"_key": "k9"
}
],
"_key": "k8"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time.",
"_key": "k11"
}
],
"_key": "k10"
}
]
},
"bylines": [
{
"byline": "byline-editorial"
},
{
"byline": "byline-guest",
"roleLabel": "Guest essay"
}
],
"taxonomies": {
"category": ["development"],
"tag": ["opinion"]
}
},
{
"id": "post-2",
"slug": "the-case-for-static",
"status": "published",
"data": {
"title": "The Case for Static",
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
"alt": "Laptop and coffee on a wooden table",
"filename": "case-for-static.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The performance argument",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"bylines": [
{
"byline": "byline-editorial"
}
],
"taxonomies": {
"category": ["development"],
"tag": ["webdev", "opinion"]
}
},
{
"id": "post-3",
"slug": "learning-in-public",
"status": "published",
"data": {
"title": "Learning in Public",
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
"alt": "Notebook and pen on a desk",
"filename": "learning-in-public.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The fear of being wrong",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"taxonomies": {
"category": ["notes"],
"tag": ["opinion"]
}
},
{
"id": "post-4",
"slug": "small-tools-big-impact",
"status": "published",
"data": {
"title": "Small Tools, Big Impact",
"excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
"alt": "Wrenches and hand tools hanging on a workshop wall",
"filename": "small-tools.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "The Unix philosophy, revisited",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"taxonomies": {
"category": ["development"],
"tag": ["tools"]
}
},
{
"id": "post-5",
"slug": "designing-with-constraints",
"status": "published",
"data": {
"title": "Designing with Constraints",
"excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
"alt": "Pencils and design tools on a desk",
"filename": "designing-with-constraints.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "Embracing the box",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"taxonomies": {
"category": ["design"],
"tag": ["creativity"]
}
},
{
"id": "post-6",
"slug": "a-weekend-with-a-side-project",
"status": "published",
"data": {
"title": "A Weekend with a Side Project",
"excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
"alt": "Code on a screen with a dark theme",
"filename": "weekend-side-project.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "Why side projects matter",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"taxonomies": {
"category": ["development"],
"tag": ["creativity"]
}
},
{
"id": "post-7",
"slug": "notes-on-simplicity",
"status": "published",
"data": {
"title": "Notes on Simplicity",
"excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
"alt": "Geometric pattern carved into white paper",
"filename": "notes-on-simplicity.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent.",
"_key": "k1"
}
],
"_key": "k0"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated.",
"_key": "k3"
}
],
"_key": "k2"
},
{
"_type": "block",
"style": "h2",
"children": [
{
"_type": "span",
"text": "Removing as a feature",
"_key": "k5"
}
],
"_key": "k4"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface.",
"_key": "k7"
}
],
"_key": "k6"
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect.",
"_key": "k9"
}
],
"_key": "k8"
}
]
},
"taxonomies": {
"category": ["notes"],
"tag": ["opinion"]
}
},
{
"id": "post-draft",
"slug": "work-in-progress",
"status": "draft",
"data": {
"title": "Work in Progress",
"excerpt": "This post is still being written.",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "This is a draft post that won't appear in the public listing.",
"_key": "k1"
}
],
"_key": "k0"
}
]
}
}
]
}
}
+281
View File
@@ -0,0 +1,281 @@
---
import type { ContentBylineCredit, EditProxy, MediaValue } from "emdash";
import { Image } from "emdash/ui";
interface Props {
title: string;
excerpt?: string;
featuredImage?: MediaValue | string;
href: string;
date?: Date;
readingTime?: number;
tags?: Array<{ slug: string; label: string }>;
bylines?: ContentBylineCredit[];
edit?: EditProxy;
}
const {
title,
excerpt,
featuredImage,
href,
date,
readingTime,
tags,
bylines,
edit,
} = Astro.props;
const formattedDate = date
? date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
---
<article class="post-card">
<a href={href} class="card-link">
{
featuredImage ? (
<div class="card-image" {...edit?.featured_image}>
<Image image={featuredImage} />
</div>
) : (
<div class="card-placeholder" />
)
}
<div class="card-body">
<div class="card-meta">
{
bylines && bylines.length > 0 && (
<>
<div class="card-bylines">
{bylines.slice(0, 1).map((credit) => (
<span class="card-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="card-byline-avatar"
/>
)}
<span class="card-byline-name">
{credit.byline.displayName}
</span>
</span>
))}
{bylines.length > 1 && (
<span
class="byline-more"
data-tooltip={bylines
.slice(1)
.map((c) => c.byline.displayName)
.join(", ")}
title={bylines
.slice(1)
.map((c) => c.byline.displayName)
.join(", ")}
tabindex="0"
>
+{bylines.length - 1}
</span>
)}
</div>
{(formattedDate || readingTime) && <span class="meta-dot" />}
</>
)
}
{formattedDate && <time>{formattedDate}</time>}
{formattedDate && readingTime && <span class="meta-dot" />}
{readingTime && <span>{readingTime} min</span>}
</div>
<h2 class="card-title" {...edit?.title}>{title}</h2>
{excerpt && <p class="card-excerpt" {...edit?.excerpt}>{excerpt}</p>}
</div>
</a>
{
tags && tags.length > 0 && (
<div class="card-tags">
{tags.slice(0, 2).map((tag) => (
<a href={`/tag/${tag.slug}`} class="card-tag">
{tag.label}
</a>
))}
</div>
)
}
</article>
<style>
.post-card {
display: flex;
flex-direction: column;
}
.card-link {
display: block;
text-decoration: none;
color: inherit;
}
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.card-link:hover .card-image img {
transform: scale(1.03);
}
.card-placeholder {
aspect-ratio: 16 / 10;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-body {
flex: 1;
}
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: 0;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.card-meta time,
.card-meta span:not(.meta-dot) {
white-space: nowrap;
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-heading);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.card-link:hover .card-title {
color: var(--color-brand);
}
.card-excerpt {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.card-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.card-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.card-bylines {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.card-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.card-byline-avatar {
width: var(--avatar-size-xs);
height: var(--avatar-size-xs);
border-radius: 50%;
object-fit: cover;
}
.card-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-more {
position: relative;
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
cursor: default;
border-radius: var(--radius);
outline-offset: 2px;
}
.byline-more:focus-visible {
outline: 2px solid var(--color-brand);
}
.byline-more[data-tooltip]:hover::after,
.byline-more[data-tooltip]:focus-visible::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background: var(--color-text);
color: var(--color-bg);
font-size: var(--font-size-xs);
font-weight: 400;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius);
pointer-events: none;
z-index: 10;
}
</style>
+908
View File
@@ -0,0 +1,908 @@
---
import { getMenuWithCacheHint, getSiteSettingsWithCacheHint } from "emdash";
import {
WidgetArea,
EmDashHead,
EmDashBodyStart,
EmDashBodyEnd,
} from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
import { Font } from "astro:assets";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
import "../styles/tokens.css";
import "../styles/theme.css";
interface Props {
title: string;
pageTitle?: string | null;
description?: string | null;
image?: string | null;
canonical?: string | null;
robots?: string | null;
type?: "website" | "article";
publishedTime?: string | null;
modifiedTime?: string | null;
author?: string | null;
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}
const {
title,
pageTitle,
description,
image,
canonical,
robots,
type = "website",
publishedTime,
modifiedTime,
author,
content,
} = Astro.props;
const [settingsResult, menuResult] = await Promise.all([
getSiteSettingsWithCacheHint(),
getMenuWithCacheHint("primary"),
]);
if (Astro.cache?.enabled) {
Astro.cache.set(settingsResult.cacheHint);
Astro.cache.set(menuResult.cacheHint);
}
const { siteTitle, siteTagline, siteLogo } = resolveBlogSiteIdentity(
settingsResult.data,
);
const titleIncludesSiteName =
title === siteTitle ||
title.endsWith(` | ${siteTitle}`) ||
title.endsWith(` — ${siteTitle}`);
const fullTitle = titleIncludesSiteName ? title : `${title} — ${siteTitle}`;
const menu = menuResult.data;
// Build public page context for plugin contributions
// SEO data is passed here and rendered securely by EmDashHead
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: type,
title: fullTitle,
pageTitle: pageTitle ?? title,
description,
canonical,
image,
content,
seo: { ogImage: image, robots },
articleMeta: { publishedTime, modifiedTime, author },
siteName: siteTitle,
});
// Check if user is logged in (for showing admin link)
const isLoggedIn = !!Astro.locals.user;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<Font cssVariable="--font-body" preload />
<Font cssVariable="--font-mono" />
<title>{fullTitle}</title>
<EmDashHead page={pageCtx} />
<script is:inline>
// Apply an explicit theme choice immediately to prevent flash.
// With no cookie, color-scheme: light dark follows the OS.
(function () {
var row = document.cookie.split("; ").find(function (item) {
return item.startsWith("theme=");
});
var theme = row ? row.slice(6) : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
}
})();
</script>
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header class="site-header">
<nav class="nav">
<a href="/" class="site-title">
{
siteLogo ? (
<img
src={siteLogo.url}
alt={siteLogo.alt || siteTitle}
class="site-logo-img"
/>
) : (
siteTitle
)
}
</a>
<div class="nav-right">
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
/>
<div class="nav-links">
{
menu?.items.map((item) => (
<a
href={item.url}
target={item.target}
rel={
item.target === "_blank" ? "noopener noreferrer" : undefined
}
>
{item.label}
</a>
))
}
</div>
</div>
{
isLoggedIn && (
<a href="/_emdash/admin" class="nav-admin">
Admin
</a>
)
}
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-grid">
<div class="footer-brand">
<a href="/" class="footer-logo">
{
siteLogo ? (
<img
src={siteLogo.url}
alt={siteLogo.alt || siteTitle}
class="footer-logo-img"
/>
) : (
siteTitle
)
}
</a>
<p class="footer-tagline">{siteTagline}</p>
</div>
<div class="footer-nav">
<h2 class="footer-heading">Navigate</h2>
<ul class="footer-links">
{
menu?.items.map((item) => (
<li>
<a
href={item.url}
target={item.target}
rel={
item.target === "_blank"
? "noopener noreferrer"
: undefined
}
>
{item.label}
</a>
</li>
))
}
</ul>
</div>
<div class="footer-nav">
<h2 class="footer-heading">Connect</h2>
<ul class="footer-links">
<li><a href="/rss.xml">RSS Feed</a></li>
</ul>
</div>
<div class="footer-widgets-section">
<WidgetArea name="footer" />
</div>
</div>
<div class="footer-bottom">
<p class="footer-copyright">
Powered by <a href="https://emdashcms.com">EmDash</a>
</p>
<div class="theme-switcher">
<button
type="button"
class="theme-btn"
data-theme="light"
aria-label="Light mode"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="12" cy="12" r="5"></circle><line
x1="12"
y1="1"
x2="12"
y2="3"></line><line x1="12" y1="21" x2="12" y2="23"
></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"
></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"
></line><line x1="1" y1="12" x2="3" y2="12"></line><line
x1="21"
y1="12"
x2="23"
y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"
></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"
></line></svg
>
</button>
<button
type="button"
class="theme-btn"
data-theme="dark"
aria-label="Dark mode"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
></path></svg
>
</button>
<button
type="button"
class="theme-btn"
data-theme="system"
aria-label="System theme"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="2" y="3" width="20" height="14" rx="2" ry="2"
></rect><line x1="8" y1="21" x2="16" y2="21"></line><line
x1="12"
y1="17"
x2="12"
y2="21"></line></svg
>
</button>
</div>
</div>
</div>
</footer>
<script>
// Theme switcher
const themeBtns =
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
const root = document.documentElement;
function setCookie(
name: string,
value: string,
maxAge: number = 31536000,
) {
const secure = location.protocol === "https:" ? "; Secure" : "";
if (value === "") {
document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax${secure}`;
} else {
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
}
}
function setTheme(theme: string) {
if (theme === "system") {
setCookie("theme", "");
root.classList.remove("light", "dark");
} else {
setCookie("theme", theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
}
updateActiveBtn(theme);
}
function updateActiveBtn(theme: string) {
themeBtns.forEach((btn) => {
const active = btn.dataset.theme === theme;
btn.classList.toggle("active", active);
btn.setAttribute("aria-pressed", String(active));
});
}
function getStoredTheme(): string {
const row = document.cookie
.split("; ")
.find((item) => item.startsWith("theme="));
return row?.slice(6) || "system";
}
// Initialize
const storedTheme = getStoredTheme();
setTheme(storedTheme);
themeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
setTheme(btn.dataset.theme || "system");
});
});
</script>
<style is:global>
/*
* Establish layer order: "base" has lower priority than unlayered
* styles, so theme.css overrides always win regardless of source
* order in the bundled CSS.
*/
@layer base;
@layer base {
*:where(:not([class*="emdash"]):not([class*="ec-"])),
*:where(:not([class*="emdash"]):not([class*="ec-"]))::before,
*:where(:not([class*="emdash"]):not([class*="ec-"]))::after {
box-sizing: border-box;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol,
figure,
blockquote,
dl,
dd {
margin: 0;
}
ul,
ol {
padding: 0;
}
/*
* Design tokens live in src/styles/tokens.css. Colors are
* defined with light-dark(); these rules pin the scheme when
* the footer switcher sets an explicit choice. With no class,
* color-scheme: light dark follows the OS preference. The
* @supports guard keeps the toggle from forcing dark UA form
* controls on browsers stuck on the plain-light fallback.
*/
@supports (color: light-dark(#000, #fff)) {
:root.light {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
a:where(:not([class*="emdash"]):not([class*="ec-"])) {
color: var(--color-brand);
text-decoration: none;
transition: color var(--transition-fast);
}
a:where(:not([class*="emdash"]):not([class*="ec-"])):hover {
color: var(--color-brand-hover);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
line-height: var(--leading-tight);
font-weight: var(--font-weight-heading);
letter-spacing: var(--tracking-snug);
}
h1 {
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
}
::selection {
background: var(--color-brand);
color: var(--color-on-brand);
}
}
</style>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: color-mix(in srgb, var(--color-bg) 65%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border-subtle);
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4) var(--spacing-6);
height: var(--nav-height);
}
.site-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: var(--tracking-snug);
}
.site-title:hover {
color: var(--color-brand);
}
.site-logo-img {
height: 48px;
width: auto;
display: block;
margin: -8px 0;
}
.nav-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
margin-inline-start: auto;
}
.nav-links {
display: flex;
gap: var(--spacing-5);
font-size: var(--font-size-sm);
}
.nav-links a {
text-decoration: none;
color: var(--color-text);
transition: color var(--transition-fast);
}
.nav-links a:hover {
color: var(--color-brand);
}
.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.nav-admin:hover {
color: var(--color-text);
}
/* Search styling */
.site-search {
position: relative;
width: var(--search-input-width);
--emdash-search-border-focus: var(--color-brand);
}
:global(.site-search-input) {
width: var(--search-input-width);
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
:global(.site-search-input)::placeholder {
color: var(--color-muted);
}
:global(.site-search-input):focus,
:global(.site-search-input):focus-visible {
outline: none;
border-color: var(--color-brand) !important;
box-shadow: 0 0 0 3px var(--color-brand-ring);
}
:global(.site-search-results) {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: var(--spacing-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
:global(.site-search-results .emdash-live-search-loading),
:global(.site-search-results .emdash-live-search-no-results) {
padding: var(--spacing-4);
text-align: center;
color: var(--color-muted);
font-size: var(--font-size-sm);
}
:global(.site-search-result) {
display: block;
padding: var(--spacing-3) var(--spacing-4);
text-decoration: none;
color: var(--color-text);
border-bottom: 1px solid var(--color-border-subtle);
transition: background var(--transition-fast);
}
:global(.site-search-result):last-child {
border-bottom: none;
}
:global(.site-search-result):hover,
:global(.site-search-result):focus,
:global(.site-search-result.focused) {
background: var(--color-surface);
outline: none;
}
:global(.site-search-result .emdash-live-search-result-title) {
display: block;
font-weight: 500;
font-size: var(--font-size-sm);
}
:global(.site-search-result .emdash-live-search-result-collection) {
display: block;
font-size: var(--font-size-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
margin-top: 2px;
}
:global(.site-search-result .emdash-live-search-result-snippet) {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-top: var(--spacing-1);
line-height: var(--leading-snug);
}
:global(.site-search-result .emdash-live-search-result-snippet mark) {
font-weight: 600;
color: var(--color-text);
}
main {
min-height: calc(100vh - var(--nav-height) - 300px);
}
/* Footer */
.site-footer {
background: var(--color-bg-subtle);
border-top: 1px solid var(--color-border-subtle);
}
.footer-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6) var(--spacing-8);
}
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: var(--spacing-12);
margin-bottom: var(--spacing-12);
}
.footer-brand {
max-width: 280px;
}
.footer-logo {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: var(--tracking-snug);
}
.footer-logo-img {
height: 24px;
width: auto;
}
.footer-tagline {
margin-top: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: var(--leading-relaxed);
}
.footer-nav {
min-width: 0;
}
.footer-heading {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.footer-links {
list-style: none;
}
.footer-links li {
margin-bottom: var(--spacing-2);
}
.footer-links a {
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}
.footer-links a:hover {
color: var(--color-text);
}
.footer-widgets-section :global(.widget-area) {
display: block;
}
.footer-widgets-section :global(.widget) {
color: var(--color-text-secondary);
}
.footer-widgets-section :global(.widget__title) {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.footer-widgets-section :global(.widget__content) {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: var(--leading-relaxed);
}
.footer-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-border);
}
.footer-copyright {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.footer-copyright a {
color: var(--color-text-secondary);
}
/* Theme switcher */
.theme-switcher {
display: flex;
gap: var(--spacing-1);
padding: var(--spacing-1);
background: var(--color-surface);
border-radius: var(--radius);
}
.theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
background: transparent;
border: none;
color: var(--color-muted);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-btn:hover {
color: var(--color-text-secondary);
}
.theme-btn.active {
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-sm);
}
.theme-btn svg {
width: 16px;
height: 16px;
}
@media (max-width: 900px) {
.footer-grid {
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.footer-brand {
grid-column: span 2;
max-width: none;
}
}
@media (max-width: 640px) {
.site-header {
position: relative;
border-bottom: none;
}
.nav {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: auto;
column-gap: var(--spacing-3);
row-gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
.site-title {
grid-column: 1;
grid-row: 1;
font-size: var(--font-size-base);
min-width: 0;
}
.nav-right {
grid-column: 1 / -1;
grid-row: 2;
display: grid;
row-gap: var(--spacing-2);
}
.site-search {
grid-column: 1;
width: 100%;
max-width: none;
}
:global(.site-search-input) {
width: 100% !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}
.nav-links {
grid-column: 1;
display: flex;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-admin {
grid-column: 2;
grid-row: 1;
justify-self: end;
font-size: var(--font-size-xs);
margin-inline-start: 0;
white-space: nowrap;
}
.footer-grid {
grid-template-columns: 1fr;
}
.footer-brand {
grid-column: span 1;
}
.footer-bottom {
flex-direction: column;
gap: var(--spacing-4);
text-align: center;
}
.footer-controls {
flex-wrap: wrap;
justify-content: center;
}
}
</style>
<script>
// ⌘K / Ctrl+K to focus search
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
const searchInput = document.querySelector(
".site-search-input",
) as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}
});
</script>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};
+33
View File
@@ -0,0 +1,33 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<div class="not-found">
<h1>404</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go back home</a>
</div>
</Base>
<style>
.not-found {
text-align: center;
padding: var(--spacing-24) var(--spacing-6);
}
.not-found h1 {
font-size: var(--font-size-5xl);
margin-bottom: var(--spacing-2);
color: var(--color-border);
}
.not-found p {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.not-found a {
color: var(--color-text);
}
</style>
+146
View File
@@ -0,0 +1,146 @@
---
import {
getTaxonomyTermsWithCacheHint,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const termsResult = await getTaxonomyTermsWithCacheHint("category", {
includeCounts: false,
});
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
Astro.cache.set(cacheHint);
}
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
// per post).
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
edit={post.edit}
/>
))}
</div>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href={`/category/${term.slug}`}>First page</a>}
{
nextCursor && (
<a
href={`/category/${term.slug}?cursor=${encodeURIComponent(nextCursor)}`}
>
Next page
</a>
)
}
</nav>
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-brand);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>
+466
View File
@@ -0,0 +1,466 @@
---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts
.filter((_, i) => i !== featuredIndex)
.slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h1>No posts yet</h1>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image" {...featuredPost.edit.featured_image}>
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title" {...featuredPost.edit.title}>{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt" {...featuredPost.edit.excerpt}>{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
edit={post.edit}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: var(--font-weight-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-brand);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-brand);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-brand-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-heading);
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-brand);
color: var(--color-on-brand);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-brand-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
---
import {
getEmDashEntry,
getSeoMeta,
decodeSlug,
getSiteSettings,
} from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
const slug = decodeSlug(Astro.params.slug);
if (!slug) {
return Astro.redirect("/404");
}
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
if (!page) {
return Astro.redirect("/404");
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings());
const seo = getSeoMeta(page, {
siteTitle,
siteUrl: Astro.url.origin,
path: Astro.url.pathname.replace(/\/$/, ""),
});
---
<Base
title={seo.title}
pageTitle={seo.ogTitle}
description={seo.description}
canonical={seo.canonical}
robots={seo.robots}
content={{ collection: "pages", id: page.data.id, slug }}
>
<article class="page-article">
<header class="page-header">
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
</header>
<div class="page-content" {...page.edit.content}>
<PortableText value={page.data.content} />
</div>
</article>
</Base>
<style>
.page-article {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: var(--font-weight-display);
line-height: var(--leading-tight);
}
.page-content :global(p) {
margin-bottom: 1.5em;
}
.page-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2em;
margin-bottom: 0.75em;
}
.page-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 1.75em;
margin-bottom: 0.5em;
}
.page-content :global(blockquote) {
margin: 1.5em 0;
padding-left: var(--spacing-6);
border-left: 3px solid var(--color-border);
color: var(--color-muted);
}
.page-content :global(pre) {
margin: 1.5em 0;
padding: var(--spacing-4);
background: var(--color-surface);
border-radius: var(--radius);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.page-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.15em 0.3em;
border-radius: var(--radius);
}
.page-content :global(pre code) {
background: none;
padding: 0;
}
.page-content :global(ul),
.page-content :global(ol) {
margin-bottom: 1.5em;
padding-left: var(--spacing-5);
}
.page-content :global(li) {
margin-bottom: 0.5em;
}
</style>
+968
View File
@@ -0,0 +1,968 @@
---
import {
getEmDashEntry,
getEmDashCollection,
getTermsForEntries,
getSeoMeta,
decodeSlug,
getSiteSettings,
} from "emdash";
import { Image, PortableText, WidgetArea } from "emdash/ui";
import { Comments, CommentForm } from "emdash/ui/comments";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
const slug = decodeSlug(Astro.params.slug);
if (!slug) {
return Astro.redirect("/404");
}
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Get featured image URL for OG fallback
// The image may have src (external) or meta.storageKey (local)
function getImageUrl(img: unknown): string | undefined {
if (!img || typeof img !== "object") return undefined;
const image = img as Record<string, unknown>;
// Check for direct src
if (typeof image.src === "string" && image.src) {
return image.src.startsWith("http")
? image.src
: `${Astro.url.origin}${image.src}`;
}
// Build from storageKey for local images
const meta = image.meta as Record<string, unknown> | undefined;
const storageKey =
(typeof meta?.storageKey === "string" ? meta.storageKey : undefined) ||
(typeof image.id === "string" ? image.id : undefined);
if (storageKey) {
return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`;
}
return undefined;
}
const featuredImageUrl = getImageUrl(post.data.featured_image);
const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings());
// Generate SEO meta from content
const seo = getSeoMeta(post, {
siteTitle,
siteUrl: Astro.url.origin,
path: Astro.url.pathname.replace(/\/$/, ""),
defaultOgImage: featuredImageUrl,
});
// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];
// Get reading time
const readingTime = getReadingTime(post.data.content);
// This post's tags are already hydrated onto the entry by getEmDashEntry —
// no extra query needed.
const tags = post.data.terms?.tag ?? [];
// Fetch a few extra recent posts in case the current post is among them.
const { entries: recentPosts, cacheHint: recentPostsCacheHint } = await getEmDashCollection(
"posts",
{
orderBy: { published_at: "desc" },
limit: 4,
},
);
if (Astro.cache?.enabled) Astro.cache.set(recentPostsCacheHint);
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
// Single batched query for related-posts tags, rather than one
// getEntryTerms() call per related post.
const otherTagsByEntry = await getTermsForEntries(
"posts",
otherPosts.map((p) => p.data.id),
"tag",
);
const otherPostsWithTags = otherPosts.map((p) => ({
post: p,
tags: otherTagsByEntry.get(p.data.id) ?? [],
bylines: p.data.bylines ?? [],
}));
const publishDate =
post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}) ?? null;
---
<Base
title={seo.title}
pageTitle={seo.ogTitle}
description={seo.description}
image={seo.ogImage}
canonical={seo.canonical}
robots={seo.robots}
type="article"
publishedTime={post.data.publishedAt?.toISOString() ?? null}
modifiedTime={post.data.updatedAt.toISOString()}
content={{ collection: "posts", id: post.data.id, slug }}
>
<article class="article">
{/* Hero: Full-width featured image */}
{
post.data.featured_image && (
<div class="article-hero" {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
)
}
{/* Three-column layout */}
<div class="article-grid">
{/* Left gutter: Meta information */}
<aside class="article-meta-col">
<div class="meta-sticky">
{
bylines.length > 0 && (
<div class="meta-block byline-block">
<span class="meta-label">
{bylines.length === 1 ? "Author" : "Authors"}
</span>
<div class="bylines">
{bylines.map((credit) => (
<div class="byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="byline-avatar"
/>
)}
<div class="byline-info">
<span class="byline-name">
{credit.byline.displayName}
</span>
{credit.roleLabel && (
<span class="byline-role">{credit.roleLabel}</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
{
publishDate && (
<div class="meta-block">
<span class="meta-label">Published</span>
<time class="meta-value">{publishDate}</time>
</div>
)
}
<div class="meta-block">
<span class="meta-label">Reading time</span>
<span class="meta-value">{readingTime} min</span>
</div>
{
tags.length > 0 && (
<div class="meta-block">
<span class="meta-label">Tags</span>
<div class="meta-tags">
{tags.map((t) => (
<a href={`/tag/${t.slug}`} class="meta-tag">
{t.label}
</a>
))}
</div>
</div>
)
}
</div>
</aside>
{/* Main content */}
<div class="article-main">
<header class="article-header">
<div class="article-meta">
{
bylines.length > 0 && (
<>
<span class="article-meta-byline">
{bylines.map((credit, i) => (
<>
{i > 0 && ", "}
{credit.byline.displayName}
</>
))}
</span>
<span class="meta-dot" />
</>
)
}
{
publishDate && (
<>
<time>{publishDate}</time>
<span class="meta-dot" />
</>
)
}
<span>{readingTime} min read</span>
</div>
<h1 class="article-title" {...post.edit.title}>{post.data.title}</h1>
{
post.data.excerpt && (
<p class="article-excerpt" {...post.edit.excerpt}>
{post.data.excerpt}
</p>
)
}
</header>
<div class="article-content">
<PortableText value={post.data.content} />
</div>
<div class="article-comments">
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
</div>
</div>
{/* Right gutter: TOC + Sidebar widgets */}
<aside class="article-sidebar">
<div class="sidebar-sticky">
<nav class="toc" aria-label="Table of contents">
<h2 class="toc-title">On this page</h2>
<div class="toc-content" id="toc-content">
<!-- Populated by JS -->
</div>
</nav>
<div class="sidebar-widgets">
<WidgetArea name="sidebar" />
</div>
</div>
</aside>
</div>
</article>
{
otherPostsWithTags.length > 0 && (
<section class="more-posts">
<div class="more-inner">
<h2 class="more-title">Continue reading</h2>
<div class="more-grid">
{otherPostsWithTags.map(
({ post: p, tags: postTags, bylines: postBylines }) => (
<PostCard
title={p.data.title}
excerpt={p.data.excerpt}
featuredImage={p.data.featured_image}
href={`/posts/${p.id}`}
date={p.data.publishedAt ?? undefined}
readingTime={getReadingTime(p.data.content)}
tags={postTags.map((t) => ({ slug: t.slug, label: t.label }))}
bylines={postBylines}
edit={p.edit}
/>
),
)}
</div>
</div>
</section>
)
}
<script>
// Build table of contents from h2/h3 headings
function buildToc() {
const content = document.querySelector(".article-content");
const tocContainer = document.getElementById("toc-content");
if (!content || !tocContainer) return;
const headings = content.querySelectorAll("h2, h3");
if (headings.length === 0) {
// Hide TOC if no headings
const toc = document.querySelector(".toc") as HTMLElement | null;
if (toc) toc.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "toc-list";
headings.forEach((heading, index) => {
// Add ID if missing
if (!heading.id) {
heading.id = `heading-${index}`;
}
const li = document.createElement("li");
li.className =
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "toc-link";
link.textContent = heading.textContent;
li.appendChild(link);
list.appendChild(li);
});
tocContainer.appendChild(list);
// Highlight current section on scroll
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
const link = tocContainer.querySelector(`a[href="#${id}"]`);
if (link) {
if (entry.isIntersecting) {
tocContainer
.querySelectorAll(".toc-link")
.forEach((l) => l.classList.remove("active"));
link.classList.add("active");
}
}
});
},
{ rootMargin: "-80px 0px -80% 0px" },
);
headings.forEach((heading) => observer.observe(heading));
}
buildToc();
</script>
</Base>
<style>
/* Article container */
.article {
max-width: var(--wide-width);
margin: 0 auto;
}
/* Hero image - full width within container */
.article-hero {
margin: var(--spacing-16) var(--spacing-6);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.article-hero img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
}
/* Three-column grid */
.article-grid {
display: grid;
grid-template-columns:
var(--meta-col-width) minmax(0, var(--content-width))
var(--gutter-width);
gap: var(--spacing-10);
justify-content: center;
padding: 0 var(--spacing-6);
margin: var(--spacing-16) 0;
}
/* Left column: Meta */
.article-meta-col {
display: block;
}
.meta-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.meta-block {
margin-bottom: var(--spacing-6);
}
.meta-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-1);
}
.meta-value {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.meta-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.meta-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.bylines {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.byline {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.byline-avatar {
width: var(--avatar-size-lg);
height: var(--avatar-size-lg);
border-radius: 50%;
object-fit: cover;
}
.byline-info {
display: flex;
flex-direction: column;
}
.byline-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.byline-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
/* Main content column */
.article-main {
min-width: 0;
}
.article-header {
margin-bottom: var(--spacing-10);
}
.article-header .article-meta {
display: none;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.article-meta-byline {
font-weight: 500;
color: var(--color-text-secondary);
}
.article-title {
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
font-weight: var(--font-weight-display);
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-4);
}
.article-excerpt {
font-size: var(--font-size-xl);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* Article content typography */
.article-content {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
}
.article-content :global(p) {
margin-bottom: 1.5em;
}
.article-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2.5em;
margin-bottom: 0.75em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 2em;
margin-bottom: 0.5em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(blockquote) {
margin: 2em 0;
padding: var(--spacing-4) var(--spacing-6);
border-left: 3px solid var(--color-border);
background: var(--color-bg-subtle);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--color-text-secondary);
font-style: italic;
}
.article-content :global(pre) {
margin: 2em 0;
padding: var(--spacing-5);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.6;
}
.article-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.2em 0.4em;
border-radius: var(--radius);
}
.article-content :global(pre code) {
background: none;
padding: 0;
}
.article-content :global(ul),
.article-content :global(ol) {
margin-bottom: 1.5em;
padding-left: 1.5em;
}
.article-content :global(li) {
margin-bottom: 0.5em;
}
.article-content :global(img) {
margin: 2em 0;
border-radius: var(--radius-lg);
}
.article-content :global(hr) {
margin: 3em 0;
border: none;
border-top: 1px solid var(--color-border);
}
.article-content :global(a) {
color: var(--color-brand);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
.article-content :global(a:hover) {
text-decoration-thickness: 2px;
}
/* Right column: TOC + Sidebar */
.article-sidebar {
display: block;
}
.sidebar-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.toc {
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--color-border-subtle);
}
.toc-title {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.toc-content :global(.toc-list) {
list-style: none;
padding: 0;
margin: 0;
}
.toc-content :global(.toc-item) {
margin-bottom: var(--spacing-1);
}
.toc-content :global(.toc-item--nested) {
padding-left: var(--spacing-3);
}
.toc-content :global(.toc-link) {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
padding: var(--spacing-1) 0;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.toc-content :global(.toc-link:hover),
.toc-content :global(.toc-link.active) {
color: var(--color-text);
}
/* Sidebar widgets */
.sidebar-widgets :global(.widget-area) {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.sidebar-widgets :global(.widget) {
font-size: var(--font-size-sm);
}
.sidebar-widgets :global(.widget__title) {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.sidebar-widgets :global(.widget__content) {
color: var(--color-text-secondary);
line-height: var(--leading-relaxed);
}
/* Sidebar search widget */
.sidebar-widgets :global(.widget-search) {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-search__input) {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-body);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.sidebar-widgets :global(.widget-search__input)::placeholder {
color: var(--color-muted);
}
.sidebar-widgets :global(.widget-search__input):focus,
.sidebar-widgets :global(.widget-search__input):focus-visible {
outline: none;
border-color: var(--color-brand);
box-shadow: 0 0 0 3px var(--color-brand-ring);
}
.sidebar-widgets :global(.widget-search__button) {
display: none;
}
/* Sidebar categories widget */
.sidebar-widgets :global(.widget-categories) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-categories li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-categories li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-categories__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-categories__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-categories__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Sidebar tags widget - pill style */
.sidebar-widgets :global(.widget-tags__cloud) {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-tags__cloud li) {
margin: 0;
}
.sidebar-widgets :global(.widget-tags__link) {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-widgets :global(.widget-tags__link:hover) {
color: var(--color-text);
background: var(--color-border);
}
.sidebar-widgets :global(.widget-tags__count) {
display: none;
}
/* Sidebar recent posts widget */
.sidebar-widgets :global(.widget-recent-posts) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-recent-posts li) {
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-recent-posts a) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.sidebar-widgets :global(.widget-recent-posts a:hover) {
color: var(--color-text);
}
/* Sidebar archives widget */
.sidebar-widgets :global(.widget-archives) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-archives li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-archives li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-archives__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-archives__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-archives__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Comments section */
.article-comments {
margin-top: var(--spacing-16);
padding-top: var(--spacing-10);
border-top: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments) {
--ec-comment-border: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments-heading) {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-heading);
margin-bottom: var(--spacing-8);
}
.article-comments :global(.ec-comment-author) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-date) {
font-family: var(--font-mono);
color: var(--color-muted);
}
.article-comments :global(.ec-comment-body) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-form-field input),
.article-comments :global(.ec-comment-form-field textarea) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
.article-comments :global(.ec-comment-user-info) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.article-comments :global(.ec-comment-form-submit) {
background: var(--color-brand) !important;
color: var(--color-on-brand) !important;
}
/* More posts section */
.more-posts {
background: var(--color-bg-subtle);
padding: var(--spacing-16) 0;
margin-top: var(--spacing-16);
}
.more-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-6);
}
.more-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-heading);
margin-bottom: var(--spacing-10);
}
.more-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
}
/* Responsive */
@media (max-width: 1100px) {
.article-grid {
grid-template-columns: minmax(0, var(--content-width));
gap: 0;
}
.article-meta-col,
.article-sidebar {
display: none;
}
.article-header .article-meta {
display: flex;
}
}
@media (max-width: 900px) {
.article-hero {
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
border-radius: var(--radius);
}
.article-grid {
padding: 0 var(--spacing-4);
}
.more-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.article-title {
font-size: var(--font-size-3xl);
}
.more-grid {
grid-template-columns: 1fr;
}
}
</style>
+289
View File
@@ -0,0 +1,289 @@
---
import { getEmDashCollection, getTermsForEntries } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
// Sort in the database rather than in JS — lets the DB use its index on
// published_at and avoids a full-table scan on the client.
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
// Single batched query for tags across all posts, instead of one
// getEntryTerms() call per post (which would be N round-trips).
// Bylines are already hydrated on entry.data.bylines.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const postsWithTags = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
bylines: post.data.bylines ?? [],
}));
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}{" "}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title" {...post.edit.title}>{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt" {...post.edit.excerpt}>{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href="/posts">First page</a>}
{
nextCursor && (
<a href={`/posts?cursor=${encodeURIComponent(nextCursor)}`}>
Next page
</a>
)
}
</nav>
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-heading);
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-brand);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site ?? new URL(url.origin);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const postUrl = new URL(`/posts/${post.id}`, siteUrl).href;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteTagline)}</description>
<link>${siteUrl.href}</link>
<atom:link href="${new URL("/rss.xml", siteUrl).href}" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}
+196
View File
@@ -0,0 +1,196 @@
---
export const prerender = false;
import { search } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q")?.trim() || "";
// Use the FTS-backed search() API instead of loading every post and
// filtering in JS. FTS scales as the post count grows, returns ranked
// results, and handles tokenization/stemming. Templates that grep all
// post bodies in JS quickly become unusable past a few hundred posts.
const { items: results } = query
? await search(query, { collections: ["posts"], limit: 30 })
: { items: [] };
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<label for="site-search-query" class="visually-hidden"
>Search blog posts</label
>
<input
id="site-search-query"
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<ol class="search-results">
{results.map((result) => (
<li class="search-result">
<a
href={`/posts/${result.slug ?? result.id}`}
class="result-link"
>
<h2 class="result-title">{result.title ?? "Untitled"}</h2>
{result.snippet && (
<p class="result-snippet" set:html={result.snippet} />
)}
</a>
</li>
))}
</ol>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-brand);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-brand);
color: var(--color-on-brand);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.search-result {
padding: var(--spacing-6) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.search-result:first-child {
padding-top: 0;
}
.search-result:last-child {
border-bottom: none;
}
.result-link {
display: block;
text-decoration: none;
color: inherit;
}
.result-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-heading);
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-brand);
}
.result-snippet {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* FTS returns <mark> wrapping the matched terms */
.result-snippet :global(mark) {
background: var(--color-brand-ring);
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
---
import {
getTaxonomyTermsWithCacheHint,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const termsResult = await getTaxonomyTermsWithCacheHint("tag", {
includeCounts: false,
});
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
where: { tag: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
Astro.cache.set(cacheHint);
}
// Single batched query for tags on every post tagged with this term,
// rather than calling getEntryTerms() per post.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<Base
title={`Posts tagged "${term.label}"`}
description={`All posts tagged with ${term.label}`}
>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Tag</span>
<h1 class="archive-title">{term.label}</h1>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts with this tag yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
edit={post.edit}
/>
))}
</div>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href={`/tag/${term.slug}`}>First page</a>}
{
nextCursor && (
<a
href={`/tag/${term.slug}?cursor=${encodeURIComponent(nextCursor)}`}
>
Next page
</a>
)
}
</nav>
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-brand);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>
+33
View File
@@ -0,0 +1,33 @@
/*
theme.css -- your theme overrides. This is the file to edit to
retheme the site.
Every design token lives in src/styles/tokens.css (colors, type
scale, spacing, layout, shadows) with its default value. Override any
of them here: declarations in this file are unlayered, so they always
beat the @layer base defaults in tokens.css.
Colors are defined with light-dark(). Overriding with a plain color
changes light and dark mode at once; use light-dark(<light>, <dark>)
to set different values per mode.
Webfonts are loaded in astro.config.mjs (--font-body, --font-mono).
Swap the loaded font there; use overrides here for system fonts or a
separate heading face.
Example:
:root {
--color-brand: light-dark(#0f766e, #2dd4bf);
--font-heading: "Iowan Old Style", Georgia, serif;
--radius: 8px;
}
*/
:root {
}
/* Keep the signed-in Admin link separated from the primary navigation. */
.nav-admin {
margin-inline-start: var(--spacing-5);
}
+146
View File
@@ -0,0 +1,146 @@
/*
tokens.css -- the design tokens for this template. These are the
defaults; to customize the site, override any variable in
src/styles/theme.css rather than editing this file. Declarations in
theme.css are unlayered, so they always beat these @layer base
defaults -- no specificity tricks needed.
Colors use light-dark(): the first value applies in light mode, the
second in dark mode. Overriding a color with a plain value changes
both modes at once; use light-dark(<light>, <dark>) in an override to
keep them distinct.
--font-body is defined by the font pipeline in astro.config.mjs, not
here. To swap the loaded webfont, edit the fonts entry there. To use a
system font, or a different face for headings only, override
--font-body / --font-heading in theme.css.
*/
@layer base {
:root {
color-scheme: light dark;
/* Colors */
--color-bg: light-dark(#ffffff, #0d0d0d);
--color-bg-subtle: light-dark(#fafafa, #141414);
--color-text: light-dark(#1a1a1a, #ededed);
--color-text-secondary: light-dark(#525252, #a0a0a0);
--color-muted: light-dark(#8b8b8b, #6b6b6b);
--color-border: light-dark(#e5e5e5, #2a2a2a);
--color-border-subtle: light-dark(#f0f0f0, #1f1f1f);
--color-surface: light-dark(#f7f7f7, #181818);
--color-brand: light-dark(#0066cc, #4d9fff);
--color-brand-hover: light-dark(#0052a3, #6eb0ff);
--color-on-brand: white;
--color-brand-ring: color-mix(in srgb, var(--color-brand) 25%, transparent);
/* EmDash search theming */
--emdash-search-bg: var(--color-bg);
--emdash-search-text: var(--color-text);
--emdash-search-muted: var(--color-muted);
--emdash-search-border: var(--color-border);
--emdash-search-hover: var(--color-surface);
--emdash-search-highlight: var(--color-text);
/* Typography */
--font-heading: var(--font-body);
--font-weight-heading: 600; /* h2-h6, card and list titles */
--font-weight-display: 700; /* h1 and large page titles */
/* Type scale */
--font-size-xs: 0.8125rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
/* Line heights */
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
/* Letter spacing */
--tracking-tight: -0.03em; /* h1 and large titles */
--tracking-snug: -0.02em; /* h2-h6, site/card titles */
--tracking-wide: 0.06em; /* meta labels, TOC/widget titles */
--tracking-wider: 0.08em; /* footer headings, section labels */
/* Spacing */
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
/* Legacy spacing aliases */
--spacing-xs: var(--spacing-1);
--spacing-sm: var(--spacing-2);
--spacing-md: var(--spacing-4);
--spacing-lg: var(--spacing-6);
--spacing-xl: var(--spacing-8);
--spacing-2xl: var(--spacing-12);
--spacing-3xl: var(--spacing-16);
/* Layout */
--content-width: 680px; /* article/page body column */
--wide-width: 1200px; /* max container (home, archives) */
--max-width: var(--content-width);
--gutter-width: 200px; /* right sidebar column (TOC) on article pages */
--meta-col-width: 180px; /* left meta column on article pages */
--nav-height: 64px; /* sticky header height */
--search-input-width: 180px; /* nav search box width */
/* Borders & radius */
--radius: 4px;
--radius-lg: 8px;
/* Transitions */
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
/* Avatars */
--avatar-size-xs: 18px; /* card byline avatars */
--avatar-size-sm: 20px; /* post list byline avatars */
--avatar-size-md: 24px; /* featured post byline avatars */
--avatar-size-lg: 32px; /* single post byline avatars */
/* Shadows */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Misc */
--tag-padding-y: 2px; /* vertical padding on tag pills */
}
/*
* Light-mode values for browsers without light-dark() (Safari < 17.5,
* Chrome < 123). Must mirror the light values above.
*/
@supports not (color: light-dark(#000, #fff)) {
:root {
color-scheme: light;
--color-bg: #ffffff;
--color-bg-subtle: #fafafa;
--color-text: #1a1a1a;
--color-text-secondary: #525252;
--color-muted: #8b8b8b;
--color-border: #e5e5e5;
--color-border-subtle: #f0f0f0;
--color-surface: #f7f7f7;
--color-brand: #0066cc;
--color-brand-hover: #0052a3;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const CJK_CHARACTERS_PER_MINUTE = 500;
const WHITESPACE_REGEX = /\s+/;
const CJK_CHARACTER_REGEX =
/\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
type PortableTextSpan = {
_type: string;
text?: string;
};
type PortableTextTextBlock = PortableTextBlock & {
_type: "block";
children: PortableTextSpan[];
};
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block" && Array.isArray(block.children);
}
function countWords(text: string): number {
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
}
function countCjkCharacters(text: string): number {
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
}
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(isTextBlock)
.map((block) =>
block.children
.filter((child) => child._type === "span" && typeof child.text === "string")
.map((span) => span.text)
.join(""),
)
.join(" ");
}
/**
* Calculate reading time in minutes from Portable Text content
*/
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
const text = extractText(content);
const cjkCharacterCount = countCjkCharacters(text);
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
const minutes = Math.ceil(
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}
+24
View File
@@ -0,0 +1,24 @@
/** Resolved media reference from getSiteSettings() */
export interface MediaReference {
mediaId: string;
alt?: string;
url?: string;
}
export interface BlogSiteIdentitySettings {
title?: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
siteLogo: settings?.logo?.url ? settings.logo : null,
};
}
+8
View File
@@ -0,0 +1,8 @@
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts", "worker-configuration.d.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-emdash-site",
"main": "./src/worker.ts",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "my-emdash-site",
},
],
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "my-emdash-media",
},
],
// Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable:
// "worker_loaders": [{ "binding": "LOADER" }],
// General maintenance cron trigger
"triggers": {
"crons": ["* * * * *"],
},
}