Initial: pi-skill — 68 skills, 43 extensions, 11 themes for Pi
This commit is contained in:
146
skills/building-emdash-site/SKILL.md
Normal file
146
skills/building-emdash-site/SKILL.md
Normal 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 |
|
||||
218
skills/building-emdash-site/references/configuration.md
Normal file
218
skills/building-emdash-site/references/configuration.md
Normal 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 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",
|
||||
"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.
|
||||
388
skills/building-emdash-site/references/querying-and-rendering.md
Normal file
388
skills/building-emdash-site/references/querying-and-rendering.md
Normal 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, "&")
|
||||
.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",
|
||||
});
|
||||
```
|
||||
462
skills/building-emdash-site/references/schema-and-seed.md
Normal file
462
skills/building-emdash-site/references/schema-and-seed.md
Normal 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
|
||||
```
|
||||
489
skills/building-emdash-site/references/site-features.md
Normal file
489
skills/building-emdash-site/references/site-features.md
Normal 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>
|
||||
```
|
||||
Reference in New Issue
Block a user