first commit
This commit is contained in:
150
templates/marketing/.agents/skills/building-emdash-site/SKILL.md
Normal file
150
templates/marketing/.agents/skills/building-emdash-site/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: building-emdash-site
|
||||
description: Build and customize EmDash CMS sites on Astro. Use when creating pages, defining collections, writing seed files, querying content, rendering Portable Text, setting up menus/taxonomies/widgets, configuring deployment, or any task involving an EmDash-powered Astro site. Assumes basic Astro knowledge but provides all EmDash-specific patterns.
|
||||
---
|
||||
|
||||
# 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. **Always pass `cacheHint` to `Astro.cache.set()`.** Every query returns a `cacheHint`. Call `Astro.cache.set(cacheHint)` on every page that queries content, or cache invalidation won't work when editors publish changes.
|
||||
|
||||
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. Validate with:
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json --validate
|
||||
```
|
||||
|
||||
### 6. Run and verify
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations + seeds, and generates types)
|
||||
```
|
||||
|
||||
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,
|
||||
getMenu,
|
||||
getTaxonomyTerms,
|
||||
getTerm,
|
||||
getEntryTerms,
|
||||
getEntriesByTerm,
|
||||
getWidgetArea,
|
||||
search,
|
||||
getSection,
|
||||
getSeoMeta,
|
||||
} from "emdash";
|
||||
|
||||
// Bylines (standalone queries -- usually not needed since entries have bylines attached)
|
||||
import { getEntryBylines, getBylinesForEntries, getByline, getBylineBySlug } from "emdash";
|
||||
|
||||
// UI components
|
||||
import {
|
||||
PortableText,
|
||||
Image,
|
||||
Comments,
|
||||
CommentForm,
|
||||
WidgetArea,
|
||||
EmDashHead,
|
||||
EmDashBodyStart,
|
||||
EmDashBodyEnd,
|
||||
} from "emdash/ui";
|
||||
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 custom block types to the Portable Text editor (e.g., embedded maps, code playgrounds, CTAs)
|
||||
- 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,193 @@
|
||||
# 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 },
|
||||
});
|
||||
```
|
||||
|
||||
### Cloudflare (D1 + R2)
|
||||
|
||||
```javascript
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
import { d1, r2 } from "@emdashcms/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",
|
||||
"compatibility_date": "2026-02-24",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"assets": { "directory": "./dist" },
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "my-site",
|
||||
"database_id": "", // from `wrangler d1 create my-site`
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "my-site-media",
|
||||
},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
### Plugins
|
||||
|
||||
Register plugins in `astro.config.mjs`:
|
||||
|
||||
```javascript
|
||||
import { auditLogPlugin } from "@emdashcms/plugin-audit-log";
|
||||
|
||||
emdash({
|
||||
database: sqlite({ url: "file:./data.db" }),
|
||||
storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }),
|
||||
plugins: [auditLogPlugin()],
|
||||
}),
|
||||
```
|
||||
|
||||
## 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": "^6.0.0",
|
||||
"emdash": "workspace:*",
|
||||
"@astrojs/node": "^9.0.0",
|
||||
"@astrojs/react": "^4.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For Cloudflare, replace `@astrojs/node` with `@astrojs/cloudflare` and add `@emdashcms/cloudflare`.
|
||||
|
||||
## Dev Server
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations, applies seed)
|
||||
npx emdash dev --types # Start and generate types from schema
|
||||
```
|
||||
|
||||
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,388 @@
|
||||
# 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");
|
||||
Astro.cache.set(cacheHint);
|
||||
---
|
||||
```
|
||||
|
||||
Always call `Astro.cache.set(cacheHint)` -- it enables automatic cache invalidation when content changes.
|
||||
|
||||
## 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 PT blocks (e.g., marketing components), pass a `components` prop:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { PortableText } from "emdash/ui";
|
||||
import Hero from "./blocks/Hero.astro";
|
||||
import Features from "./blocks/Features.astro";
|
||||
|
||||
const customTypes = {
|
||||
"marketing.hero": Hero,
|
||||
"marketing.features": Features,
|
||||
};
|
||||
---
|
||||
<PortableText value={page.data.content} components={{ type: customTypes }} />
|
||||
```
|
||||
|
||||
Each custom component receives the block data as props.
|
||||
|
||||
## 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, getEntryTerms } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const sortedPosts = posts.toSorted((a, b) => {
|
||||
const dateA = a.data.publishedAt?.getTime() ?? 0;
|
||||
const dateB = b.data.publishedAt?.getTime() ?? 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
---
|
||||
<Base title="Posts">
|
||||
{sortedPosts.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, getEntryTerms, 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");
|
||||
|
||||
Astro.cache.set(cacheHint);
|
||||
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
});
|
||||
|
||||
const tags = await getEntryTerms("posts", post.data.id, "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 { getTerm, getEmDashCollection } from "emdash";
|
||||
import Base from "../../layouts/Base.astro";
|
||||
|
||||
const { slug } = Astro.params;
|
||||
const term = slug ? await getTerm("category", slug) : null;
|
||||
if (!term) return Astro.redirect("/404");
|
||||
|
||||
const { entries: posts } = await getEmDashCollection("posts", {
|
||||
where: { category: term.slug },
|
||||
orderBy: { published_at: "desc" },
|
||||
});
|
||||
---
|
||||
<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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
```
|
||||
|
||||
### 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,469 @@
|
||||
# Schema and Seed Files
|
||||
|
||||
The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's applied on first run or via `npx emdash seed seed/seed.json`.
|
||||
|
||||
## 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": { ... },
|
||||
"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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
| `image` | TEXT | `{ id, src?, alt?, width?, height? }` | **Object, not a string** |
|
||||
| `reference` | TEXT | `string` (ID) | Reference to another entry |
|
||||
| `portableText` | JSON | `PortableTextBlock[]` | Rich text as structured JSON |
|
||||
| `json` | JSON | `any` | 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": "json" },
|
||||
{ "slug": "url", "label": "Project URL", "type": "string" }
|
||||
]
|
||||
```
|
||||
|
||||
**Page (minimal):**
|
||||
|
||||
```json
|
||||
"fields": [
|
||||
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
|
||||
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true }
|
||||
]
|
||||
```
|
||||
|
||||
## 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": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json --validate
|
||||
```
|
||||
|
||||
Catches:
|
||||
|
||||
- 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.)
|
||||
|
||||
## Applying Seeds
|
||||
|
||||
```bash
|
||||
npx emdash seed seed/seed.json # Apply with content
|
||||
npx emdash seed seed/seed.json --no-content # Schema only (no sample content)
|
||||
```
|
||||
|
||||
## 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,495 @@
|
||||
# Site Features
|
||||
|
||||
## Site Settings
|
||||
|
||||
```typescript
|
||||
import { getSiteSettings, 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");
|
||||
```
|
||||
|
||||
Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.
|
||||
|
||||
Use these instead of hard-coding site name, logo, etc.
|
||||
|
||||
## Navigation Menus
|
||||
|
||||
```typescript
|
||||
import { getMenu, getMenus } from "emdash";
|
||||
|
||||
// Fetch a named menu
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
// List all menus
|
||||
const menus = await getMenus();
|
||||
```
|
||||
|
||||
### 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}>{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, 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");
|
||||
|
||||
// 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 = await getEntryTerms("posts", post.data.id, "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>
|
||||
```
|
||||
|
||||
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,
|
||||
});
|
||||
// { results: SearchResult[], total, nextCursor? }
|
||||
```
|
||||
|
||||
Each result has: `collection`, `id`, `title`, `slug`, `snippet` (HTML with `<mark>` highlights), `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
|
||||
|
||||
Generate SEO meta from content entries:
|
||||
|
||||
```typescript
|
||||
import { getSeoMeta } from "emdash";
|
||||
|
||||
const seo = getSeoMeta(post, {
|
||||
siteTitle: "My Blog",
|
||||
siteUrl: Astro.url.origin,
|
||||
path: `/posts/${slug}`,
|
||||
defaultOgImage: featuredImageUrl, // Optional fallback
|
||||
});
|
||||
|
||||
// Returns: { title, description, canonical, ogImage, robots }
|
||||
```
|
||||
|
||||
Use in your layout's `<head>`:
|
||||
|
||||
```astro
|
||||
<title>{seo.title}</title>
|
||||
<meta name="description" content={seo.description} />
|
||||
<link rel="canonical" href={seo.canonical} />
|
||||
<meta property="og:image" content={seo.ogImage} />
|
||||
{seo.robots && <meta name="robots" content={seo.robots} />}
|
||||
```
|
||||
|
||||
## Comments
|
||||
|
||||
Built-in comments system:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { Comments, CommentForm } from "emdash/ui";
|
||||
---
|
||||
<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,
|
||||
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 { getEntryBylines, getByline, getBylineBySlug, getBylinesForEntries } from "emdash";
|
||||
|
||||
// Bylines for a single entry
|
||||
const credits = await getEntryBylines("posts", post.data.id);
|
||||
|
||||
// Batch-fetch for a list page (avoids N+1)
|
||||
const ids = entries.map((e) => e.data.id);
|
||||
const bylinesMap = await getBylinesForEntries("posts", ids);
|
||||
// bylinesMap.get(entryId) => ContentBylineCredit[]
|
||||
|
||||
// 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, description, image, content } = Astro.props;
|
||||
const menu = await getMenu("primary");
|
||||
|
||||
const pageCtx = createPublicPageContext({
|
||||
Astro,
|
||||
kind: content ? "content" : "custom",
|
||||
pageType: "website",
|
||||
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>
|
||||
```
|
||||
Reference in New Issue
Block a user