Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi

This commit is contained in:
Kunthawat Greethong
2026-05-25 16:38:02 +07:00
commit 69f7d8bdda
1689 changed files with 342427 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
---
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.
### 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 { 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 |

View File

@@ -0,0 +1,218 @@
# 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 browsers 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",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
"assets": { "directory": "./dist" },
"d1_databases": [
{
"binding": "DB",
"database_name": "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,462 @@
# 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": { ... },
"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": { ... }
}
```
## 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
```

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