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:
1 parent
af8dd75fd1
commit
4b4ef8fea6
52 files changed
+18118
No files matched your search
@@ -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, "&")
|
||||
.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,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>
|
||||
```
|
||||
Reference in new issue
Block a user