Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -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 "@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",
"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 "@emdash-cms/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 `@emdash-cms/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.

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
```
### 404 page (`/404.astro`)
```astro
---
import Base from "../layouts/Base.astro";
---
<Base title="Not Found">
<h1>Page not found</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go home</a>
</Base>
```
### Empty state
When a collection has no content, show a helpful empty state:
```astro
{posts.length === 0 ? (
<section>
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new">Create a post</a>
</section>
) : (
/* ... render posts ... */
)}
```
## Pagination
`getEmDashCollection` supports cursor-based keyset pagination. Pass `cursor` from a previous result's `nextCursor` to get the next page:
```astro
---
const cursor = Astro.url.searchParams.get("cursor") ?? undefined;
const { entries, nextCursor, cacheHint } = await getEmDashCollection("posts", {
limit: 10,
cursor,
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
---
{entries.map(post => (
<a href={`/posts/${post.id}`}>{post.data.title}</a>
))}
{nextCursor && <a href={`?cursor=${nextCursor}`}>Next page</a>}
```
`nextCursor` is `undefined` when there are no more results.
## Date Formatting
Dates come as `Date` objects. Use `toLocaleDateString` or `Intl.DateTimeFormat`:
```typescript
const formatted = post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
```

View File

@@ -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
```

View File

@@ -0,0 +1,489 @@
# 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,
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>
```