From 4b4ef8fea6eaf23d412b9e64a3643f6a5af406a1 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 7 Oct 2026 19:57:43 -0500 Subject: [PATCH] capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box) pnpm create emdash@latest potluck --pm pnpm --install --yes, per https://docs.emdashcms.com/ ("Create your first EmDash site"); the --yes defaults are cloudflare + blog. Replaces the old madmode/ starter (never committed). Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5 --- .../skills/building-emdash-site/SKILL.md | 151 + .../references/configuration.md | 234 + .../references/querying-and-rendering.md | 411 + .../references/schema-and-seed.md | 529 ++ .../references/site-features.md | 540 ++ .../.agents/skills/creating-plugins/SKILL.md | 134 + .../creating-plugins/references/admin-ui.md | 211 + .../creating-plugins/references/api-routes.md | 245 + .../creating-plugins/references/block-kit.md | 584 ++ .../creating-plugins/references/comments.md | 13 + .../creating-plugins/references/content.md | 41 + .../creating-plugins/references/hooks.md | 585 ++ .../creating-plugins/references/media.md | 25 + .../references/portable-text-blocks.md | 253 + .../creating-plugins/references/publishing.md | 101 + .../references/sandbox-boundaries.md | 43 + .../creating-plugins/references/storage.md | 213 + .../references/taxonomies-and-redirects.md | 19 + .../.agents/skills/emdash-cli/EDITING-FLOW.md | 180 + potluck/.agents/skills/emdash-cli/SKILL.md | 267 + potluck/.claude/skills | 1 + potluck/.cursor/mcp.json | 8 + potluck/.gitignore | 14 + potluck/.mcp.json | 8 + potluck/.vscode/mcp.json | 8 + potluck/AGENTS.md | 107 + potluck/README.md | 68 + potluck/astro.config.mjs | 38 + potluck/emdash-env.d.ts | 43 + potluck/package.json | 32 + potluck/pnpm-lock.yaml | 8106 +++++++++++++++++ potluck/pnpm-workspace.yaml | 15 + potluck/seed/seed.json | 940 ++ potluck/src/components/PostCard.astro | 281 + potluck/src/layouts/Base.astro | 908 ++ potluck/src/live.config.ts | 13 + potluck/src/pages/404.astro | 33 + potluck/src/pages/category/[slug].astro | 146 + potluck/src/pages/index.astro | 466 + potluck/src/pages/pages/[slug].astro | 124 + potluck/src/pages/posts/[slug].astro | 968 ++ potluck/src/pages/posts/index.astro | 289 + potluck/src/pages/rss.xml.ts | 70 + potluck/src/pages/search.astro | 196 + potluck/src/pages/tag/[slug].astro | 148 + potluck/src/styles/theme.css | 33 + potluck/src/styles/tokens.css | 146 + potluck/src/utils/reading-time.ts | 66 + potluck/src/utils/site-identity.ts | 24 + potluck/src/worker.ts | 8 + potluck/tsconfig.json | 7 + potluck/wrangler.jsonc | 25 + 52 files changed, 18118 insertions(+) create mode 100644 potluck/.agents/skills/building-emdash-site/SKILL.md create mode 100644 potluck/.agents/skills/building-emdash-site/references/configuration.md create mode 100644 potluck/.agents/skills/building-emdash-site/references/querying-and-rendering.md create mode 100644 potluck/.agents/skills/building-emdash-site/references/schema-and-seed.md create mode 100644 potluck/.agents/skills/building-emdash-site/references/site-features.md create mode 100644 potluck/.agents/skills/creating-plugins/SKILL.md create mode 100644 potluck/.agents/skills/creating-plugins/references/admin-ui.md create mode 100644 potluck/.agents/skills/creating-plugins/references/api-routes.md create mode 100644 potluck/.agents/skills/creating-plugins/references/block-kit.md create mode 100644 potluck/.agents/skills/creating-plugins/references/comments.md create mode 100644 potluck/.agents/skills/creating-plugins/references/content.md create mode 100644 potluck/.agents/skills/creating-plugins/references/hooks.md create mode 100644 potluck/.agents/skills/creating-plugins/references/media.md create mode 100644 potluck/.agents/skills/creating-plugins/references/portable-text-blocks.md create mode 100644 potluck/.agents/skills/creating-plugins/references/publishing.md create mode 100644 potluck/.agents/skills/creating-plugins/references/sandbox-boundaries.md create mode 100644 potluck/.agents/skills/creating-plugins/references/storage.md create mode 100644 potluck/.agents/skills/creating-plugins/references/taxonomies-and-redirects.md create mode 100644 potluck/.agents/skills/emdash-cli/EDITING-FLOW.md create mode 100644 potluck/.agents/skills/emdash-cli/SKILL.md create mode 120000 potluck/.claude/skills create mode 100644 potluck/.cursor/mcp.json create mode 100644 potluck/.gitignore create mode 100644 potluck/.mcp.json create mode 100644 potluck/.vscode/mcp.json create mode 100644 potluck/AGENTS.md create mode 100644 potluck/README.md create mode 100644 potluck/astro.config.mjs create mode 100644 potluck/emdash-env.d.ts create mode 100644 potluck/package.json create mode 100644 potluck/pnpm-lock.yaml create mode 100644 potluck/pnpm-workspace.yaml create mode 100644 potluck/seed/seed.json create mode 100644 potluck/src/components/PostCard.astro create mode 100644 potluck/src/layouts/Base.astro create mode 100644 potluck/src/live.config.ts create mode 100644 potluck/src/pages/404.astro create mode 100644 potluck/src/pages/category/[slug].astro create mode 100644 potluck/src/pages/index.astro create mode 100644 potluck/src/pages/pages/[slug].astro create mode 100644 potluck/src/pages/posts/[slug].astro create mode 100644 potluck/src/pages/posts/index.astro create mode 100644 potluck/src/pages/rss.xml.ts create mode 100644 potluck/src/pages/search.astro create mode 100644 potluck/src/pages/tag/[slug].astro create mode 100644 potluck/src/styles/theme.css create mode 100644 potluck/src/styles/tokens.css create mode 100644 potluck/src/utils/reading-time.ts create mode 100644 potluck/src/utils/site-identity.ts create mode 100644 potluck/src/worker.ts create mode 100644 potluck/tsconfig.json create mode 100644 potluck/wrangler.jsonc diff --git a/potluck/.agents/skills/building-emdash-site/SKILL.md b/potluck/.agents/skills/building-emdash-site/SKILL.md new file mode 100644 index 0000000..f519a66 --- /dev/null +++ b/potluck/.agents/skills/building-emdash-site/SKILL.md @@ -0,0 +1,151 @@ +--- +name: building-emdash-site +description: Build the site-facing parts of an EmDash CMS project on Astro, including schema and seeds, content queries, Portable Text rendering, menus, taxonomies, widgets, and deployment configuration. Use for EmDash site and theme work; use creating-plugins instead when the task is plugin authoring alone. +--- + +# 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 `` renders `[object Object]`. Use `` 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. **Register cache hints when Astro's cache is enabled.** Content queries return a `cacheHint`; pass it to `Astro.cache.set(cacheHint)`. Use `getSiteSettingsWithCacheHint()`, `getMenuWithCacheHint()`, `getTaxonomyTermsWithCacheHint()`, and `getWidgetAreaWithCacheHint()` when a cached route renders those values. + +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 +pnpm dev # Start the Astro dev server +``` + +The runtime runs pending migrations on the first request and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates `emdash-env.d.ts` when the server starts. The admin UI is at `http://localhost:4321/_emdash/admin`. + +## 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, + getSiteSettingsWithCacheHint, + getMenu, + getMenuWithCacheHint, + getTaxonomyTerms, + getTaxonomyTermsWithCacheHint, + getTerm, + getEntryTerms, + getEntriesByTerm, + getWidgetArea, + getWidgetAreaWithCacheHint, + 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, + Blocks, + defineBlockComponents, + Image, + WidgetArea, + EmDashHead, + EmDashBodyStart, + EmDashBodyEnd, +} from "emdash/ui"; +import { Comments, CommentForm } from "emdash/ui/comments"; +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 reusable behavior or custom Portable Text types (e.g., embedded maps or code playgrounds) +- 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 | diff --git a/potluck/.agents/skills/building-emdash-site/references/configuration.md b/potluck/.agents/skills/building-emdash-site/references/configuration.md new file mode 100644 index 0000000..fae7520 --- /dev/null +++ b/potluck/.agents/skills/building-emdash-site/references/configuration.md @@ -0,0 +1,234 @@ +# Configuration + +## astro.config.mjs + +### Node.js (local development / self-hosted) + +```javascript +import node from "@astrojs/node"; +import react from "@astrojs/react"; +import { defineConfig } from "astro/config"; +import emdash, { local } from "emdash/astro"; +import { sqlite } from "emdash/db"; + +export default defineConfig({ + output: "server", + adapter: node({ mode: "standalone" }), + image: { + layout: "constrained", + responsiveStyles: true, + }, + integrations: [ + react(), + emdash({ + database: sqlite({ url: "file:./data.db" }), + storage: local({ + directory: "./uploads", + baseUrl: "/_emdash/api/media/file", + }), + }), + ], + devToolbar: { enabled: false }, +}); +``` + +### Reverse proxy + +When behind a TLS-terminating reverse proxy, `Astro.url` returns the internal address (e.g. `http://localhost:4321`) instead of the public one (`https://mysite.example.com`). This breaks passkeys, CSRF, OAuth, redirects, and more. + +**Step 1:** Declare allowed public hosts via [`security.allowedDomains`](https://docs.astro.build/en/reference/configuration-reference/#securityalloweddomains) so Astro reconstructs the URL from `X-Forwarded-*` headers. In dev, add matching **`vite.server.allowedHosts`** or Vite rejects the proxy `Host`. + +**Step 2:** If the reconstructed URL still disagrees with the browser (common with TLS termination), set **`siteUrl`**: + +```javascript +emdash({ + siteUrl: "https://mysite.example.com", + // ... +}); +``` + +Or via environment variable (useful for container deployments): + +```bash +EMDASH_SITE_URL=https://mysite.example.com +# or: SITE_URL=https://mysite.example.com +``` + +`siteUrl` replaces `passkeyPublicOrigin` (which only fixed passkeys). It applies to passkeys, CSRF origin matching, OAuth redirects, login redirects, MCP discovery, snapshot exports, sitemap, robots.txt, and JSON-LD structured data. + +With TLS terminated in front, **`astro dev --host 127.0.0.1`** (loopback) is usually enough: the proxy reaches the dev server locally while **`siteUrl`** matches the browser’s HTTPS origin -- without opening the Node port on the LAN. + +### Cloudflare (D1 + R2) + +```javascript +import cloudflare from "@astrojs/cloudflare"; +import react from "@astrojs/react"; +import { d1, r2 } from "@emdash-cms/cloudflare"; +import { defineConfig } from "astro/config"; +import emdash from "emdash/astro"; + +export default defineConfig({ + output: "server", + adapter: cloudflare(), + image: { + layout: "constrained", + responsiveStyles: true, + }, + integrations: [ + react(), + emdash({ + database: d1({ binding: "DB", session: "auto" }), + storage: r2({ binding: "MEDIA" }), + }), + ], + devToolbar: { enabled: false }, +}); +``` + +Requires a `wrangler.jsonc` with D1 and R2 bindings: + +```jsonc +{ + "name": "my-site", + "main": "./src/worker.ts", + "compatibility_date": "2026-02-24", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-site", + }, + ], + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "my-site-media", + }, + ], + "triggers": { + "crons": ["* * * * *"], + }, +} +``` + +Use the EmDash Worker entry point so scheduled publishing and maintenance run through the same deployment: + +```typescript +import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker"; + +export { PluginBridge }; + +export default { + ...handler, + scheduled: createScheduledHandler(), +} satisfies ExportedHandler; +``` + +### Plugins + +Register plugins in `astro.config.mjs`: + +```javascript +import auditLog from "@emdash-cms/plugin-audit-log"; + +emdash({ + database: sqlite({ url: "file:./data.db" }), + storage: local({ directory: "./uploads", baseUrl: "/_emdash/api/media/file" }), + plugins: [auditLog], +}), +``` + +## live.config.ts + +Every EmDash site needs this file at `src/live.config.ts`. It's boilerplate -- the same in every project: + +```typescript +import { defineLiveCollection } from "astro:content"; +import { emdashLoader } from "emdash/runtime"; + +export const collections = { + _emdash: defineLiveCollection({ loader: emdashLoader() }), +}; +``` + +This registers EmDash's live content collections with Astro. All content types are served through the single `_emdash` collection -- you query specific types using `getEmDashCollection("posts")` etc. + +## emdash-env.d.ts + +Auto-generated at the project root when the dev server starts. Provides TypeScript types for your collections. This is the file your `tsconfig.json` includes. + +```typescript +/// + +import type { PortableTextBlock } from "emdash"; + +export interface Post { + id: string; + slug: string | null; + status: string; + title: string; + featured_image?: { + id: string; + src?: string; + alt?: string; + width?: number; + height?: number; + }; + content?: PortableTextBlock[]; + excerpt?: string; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; +} + +declare module "emdash" { + interface EmDashCollections { + posts: Post; + } +} +``` + +The dev server regenerates this file automatically when schema changes. You can also generate it manually: + +## Type Generation + +```bash +# From local dev server (writes emdash-env.d.ts at project root) +npx emdash types + +# From remote instance +npx emdash types --url https://my-site.pages.dev + +# Custom output path +npx emdash types --output src/types/cms.ts +``` + +The CLI also writes `.emdash/schema.json` with the raw schema for tooling. + +## package.json + +Key dependencies for a Node.js site: + +```json +{ + "dependencies": { + "astro": "^7.0.0", + "emdash": "workspace:*", + "@astrojs/node": "^11.0.0", + "@astrojs/react": "^4.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +For Cloudflare, replace `@astrojs/node` with `@astrojs/cloudflare` and add `@emdash-cms/cloudflare`. + +## Dev Server + +```bash +pnpm dev # Start the Astro dev server +npx emdash types # Refresh types from the running site +``` + +The runtime runs pending migrations on the first request and applies the bundled seed when the database is empty and setup has not been completed. The Astro integration generates `emdash-env.d.ts` when the server starts. The admin UI is at `http://localhost:4321/_emdash/admin`. On first run, you'll go through setup to create an admin account. diff --git a/potluck/.agents/skills/building-emdash-site/references/querying-and-rendering.md b/potluck/.agents/skills/building-emdash-site/references/querying-and-rendering.md new file mode 100644 index 0000000..88fdde0 --- /dev/null +++ b/potluck/.agents/skills/building-emdash-site/references/querying-and-rendering.md @@ -0,0 +1,411 @@ +# Querying and Rendering Content + +## Content Queries + +All query functions are imported from `"emdash"`. + +### getEmDashCollection + +Fetch multiple entries from a collection. Returns `{ entries, error, cacheHint, nextCursor }`. + +```typescript +import { getEmDashCollection } from "emdash"; + +// Basic +const { entries: posts } = await getEmDashCollection("posts"); + +// With options +const { entries: posts, cacheHint } = await getEmDashCollection("posts", { + status: "published", + limit: 10, + orderBy: { published_at: "desc" }, + where: { category: "news" }, +}); +``` + +Options: + +- `status` -- filter by status (`"published"`, `"draft"`, etc.) +- `limit` -- max entries +- `cursor` -- opaque cursor for keyset pagination (pass `nextCursor` from a previous result) +- `orderBy` -- `{ field: "asc" | "desc" }` (default: `{ created_at: "desc" }`) +- `where` -- filter by field values or taxonomy terms. Supports arrays for OR: `{ category: ["news", "featured"] }` +- `locale` -- filter by locale (when i18n is configured) + +### getEmDashEntry + +Fetch a single entry by slug. Returns `{ entry, error, isPreview, cacheHint }`. + +```typescript +import { getEmDashEntry } from "emdash"; + +const { entry: post, cacheHint } = await getEmDashEntry("posts", slug); + +if (!post) { + return Astro.redirect("/404"); +} +``` + +### Entry Shape + +```typescript +interface ContentEntry { + id: string; // The slug (used in URLs) + data: T; // All fields, including system fields + edit: EditProxy; // Visual editing attributes (spread onto elements) +} + +// data includes system fields plus your custom fields: +interface PostData { + id: string; // Database ULID (use for taxonomy lookups, etc.) + slug: string; + status: string; + title: string; + featured_image?: { + id: string; + src?: string; + alt?: string; + width?: number; + height?: number; + }; + content?: PortableTextBlock[]; + createdAt: Date; + updatedAt: Date; + publishedAt: Date | null; + // Bylines (eagerly loaded) + byline: BylineSummary | null; // Primary author + bylines: ContentBylineCredit[]; // All credits (with roleLabel, source) + // ... your custom fields +} +``` + +**Important:** `entry.id` is the slug (for URLs), `entry.data.id` is the database ULID (for API calls like `getEntryTerms`). + +### Caching + +Query results include a `cacheHint` for Astro's Route Caching: + +```astro +--- +const { entries: posts, cacheHint } = await getEmDashCollection("posts"); +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); +--- +``` + +When Astro's route cache is enabled, call `Astro.cache.set(cacheHint)` so publishing invalidates cached output. + +## Rendering Portable Text + +### PortableText component + +```astro +--- +import { PortableText } from "emdash/ui"; +--- + +``` + +Renders standard blocks (paragraphs, headings, lists, blockquotes, code blocks, images) and inline marks (bold, italic, code, strikethrough, links). + +### Custom block types + +For custom Portable Text objects that belong inside a rich-text document, pass a `components` prop: + +```astro +--- +import { PortableText } from "emdash/ui"; +import Diagram from "./blocks/Diagram.astro"; + +const customTypes = { + "publication.diagram": Diagram, +}; +--- + +``` + +Each custom component receives the block data as props. + +## Rendering a blocks field + +Use `Blocks` for an ordered composition stored in a `blocks` collection field. Map each generated `_type` to an Astro component at the call site. + +```astro +--- +import { Blocks, defineBlockComponents } from "emdash/ui"; +import type { PageLayoutBlock } from "../../emdash-env"; +import Hero from "../components/blocks/Hero.astro"; +import FeatureGrid from "../components/blocks/FeatureGrid.astro"; + +const components = defineBlockComponents({ + hero: Hero, + feature_grid: FeatureGrid, +}); +--- + + +``` + +Each component receives `{ value, index, blockKey }`. The value retains `_version`, so a renderer can narrow old and active shapes. `Blocks` performs no database or network queries. + +An unmapped type produces a visible development placeholder. In production it renders the optional `fallback` component or no output. Ship renderer support before activating a new breaking block version. + +## Image Component + +**Always use the EmDash Image component for CMS images.** Image fields are objects, not strings. + +```astro +--- +import { Image } from "emdash/ui"; +--- + +{/* Correct -- passes the image object */} + + +{/* Also works with explicit props */} +{post.data.featured_image?.src && ( + {post.data.featured_image.alt +)} +``` + +**Common mistake:** + +```astro +{/* WRONG -- image is an object, not a string */} + +``` + +## Visual Editing Attributes + +Entries include `edit` attributes for inline editing. Spread them onto the element that displays the field: + +```astro +

{post.data.title}

+

{post.data.excerpt}

+
+ +
+``` + +When an admin is logged in and views the site, these attributes enable click-to-edit functionality. + +## Common Page Patterns + +### List page (e.g., `/posts/index.astro`) + +```astro +--- +import { getEmDashCollection } from "emdash"; +import { Image } from "emdash/ui"; +import Base from "../../layouts/Base.astro"; + +const { entries: posts, cacheHint } = await getEmDashCollection("posts", { + orderBy: { published_at: "desc" }, + limit: 20, +}); +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); +--- + + {posts.map(post => ( +
+ {post.data.featured_image && } + {post.data.title} + {post.data.excerpt &&

{post.data.excerpt}

} +
+ ))} + +``` + +### Detail page (e.g., `/posts/[slug].astro`) + +```astro +--- +import { getEmDashEntry, getSeoMeta } from "emdash"; +import { Image, PortableText } from "emdash/ui"; +import Base from "../../layouts/Base.astro"; + +const { slug } = Astro.params; +if (!slug) return Astro.redirect("/404"); + +const { entry: post, cacheHint } = await getEmDashEntry("posts", slug); +if (!post) return Astro.redirect("/404"); + +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); + +const seo = getSeoMeta(post, { + siteTitle: "My Blog", + siteUrl: Astro.url.origin, + path: `/posts/${slug}`, +}); + +const tags = post.data.terms?.tag ?? []; +--- + +
+ {post.data.featured_image && ( +
+ +
+ )} +

{post.data.title}

+ + {tags.length > 0 && ( +
+ {tags.map(t => {t.label})} +
+ )} +
+ +``` + +### Taxonomy archive (e.g., `/category/[slug].astro`) + +```astro +--- +import { getTaxonomyTermsWithCacheHint, getEmDashCollection } from "emdash"; +import Base from "../../layouts/Base.astro"; + +const { slug } = Astro.params; +const termsResult = await getTaxonomyTermsWithCacheHint("category", { includeCounts: false }); +const term = slug ? termsResult.data.find((item) => item.slug === slug) : null; +if (!term) return Astro.redirect("/404"); + +const { entries: posts, cacheHint } = await getEmDashCollection("posts", { + where: { category: term.slug }, + orderBy: { published_at: "desc" }, + limit: 20, +}); +if (Astro.cache?.enabled) { + Astro.cache.set(termsResult.cacheHint); + Astro.cache.set(cacheHint); +} +--- + +

{term.label}

+ {posts.map(post => ( + {post.data.title} + ))} + +``` + +### 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 ` + ${escapeXml(post.data.title)} + ${postUrl} + ${postUrl} + ${post.data.publishedAt!.toUTCString()} + ${escapeXml(post.data.excerpt || "")} + `; + }) + .join("\n"); + + return new Response( + ` + + + ${escapeXml(siteTitle)} + ${siteUrl} + + en-us + ${new Date().toUTCString()} +${items} + +`, + { + 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, "'"); +} +``` + +### 404 page (`/404.astro`) + +```astro +--- +import Base from "../layouts/Base.astro"; +--- + +

Page not found

+

The page you're looking for doesn't exist.

+ Go home + +``` + +### Empty state + +When a collection has no content, show a helpful empty state: + +```astro +{posts.length === 0 ? ( +
+

No posts yet

+

Create your first post in the admin panel.

+ Create a post +
+) : ( + /* ... 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 => ( + {post.data.title} +))} +{nextCursor && Next page} +``` + +`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", +}); +``` diff --git a/potluck/.agents/skills/building-emdash-site/references/schema-and-seed.md b/potluck/.agents/skills/building-emdash-site/references/schema-and-seed.md new file mode 100644 index 0000000..2a827da --- /dev/null +++ b/potluck/.agents/skills/building-emdash-site/references/schema-and-seed.md @@ -0,0 +1,529 @@ +# Schema and Seed Files + +The seed file (`seed/seed.json`) defines the site's entire schema and optional demo content. It's inlined into the build and applied automatically on the first request when the database is empty and the setup wizard hasn't been completed. + +## Seed File Structure + +```json +{ + "$schema": "https://emdashcms.com/seed.schema.json", + "version": "1", + "meta": { + "name": "My Site", + "description": "A description of this site", + "author": "Author Name" + }, + "settings": { ... }, + "blockTypes": [ ... ], + "collections": [ ... ], + "taxonomies": [ ... ], + "menus": [ ... ], + "widgetAreas": [ ... ], + "sections": [ ... ], + "bylines": [ ... ], + "content": { ... } +} +``` + +## Collections + +Collections define content types. Each collection becomes a database table (`ec_{slug}`). + +```json +{ + "slug": "posts", + "label": "Posts", + "labelSingular": "Post", + "supports": ["drafts", "revisions", "search", "seo"], + "commentsEnabled": true, + "fields": [ ... ] +} +``` + +### Collection Supports + +| Support | Description | +| ------------ | ------------------------- | +| `drafts` | Draft/published workflow | +| `revisions` | Revision history | +| `preview` | Signed draft previews | +| `scheduling` | Scheduled publication | +| `search` | Full-text search indexing | +| `seo` | SEO meta fields in admin | + +### Slug Rules + +- Lowercase alphanumeric + underscores: `/^[a-z][a-z0-9_]*$/` +- Max 63 characters +- Cannot conflict with reserved slugs + +## Field Types + +| Type | Column type | Runtime shape | Notes | +| -------------- | ----------- | ------------------------------------- | ---------------------------- | +| `string` | TEXT | `string` | Single line text | +| `text` | TEXT | `string` | Multi-line text (textarea) | +| `url` | TEXT | `string` | URL input | +| `number` | REAL | `number` | Floating point | +| `integer` | INTEGER | `number` | Whole numbers | +| `boolean` | INTEGER | `boolean` | Stored as 0/1 | +| `datetime` | TEXT | `Date` | ISO 8601 string in DB | +| `select` | TEXT | `string` | One configured option | +| `multiSelect` | JSON | `string[]` | Configured option list | +| `image` | TEXT | `{ id, src?, alt?, width?, height? }` | **Object, not a string** | +| `file` | TEXT | `{ id, url?, filename?, ... }` | File reference | +| `reference` | TEXT | `string` (ID) | Reference to another entry | +| `slug` | TEXT | `string` | Slug input | +| `repeater` | JSON | `object[]` | Repeated structured rows | +| `portableText` | JSON | `PortableTextBlock[]` | Rich text as structured JSON | +| `blocks` | JSON | `{ _type, _version, _key, ... }[]` | Ordered typed composition | +| `json` | JSON | `unknown` | Arbitrary JSON data | + +### Field Definition + +```json +{ + "slug": "title", + "label": "Title", + "type": "string", + "required": true, + "searchable": true +} +``` + +Fields can have: + +- `slug` (required) -- field identifier +- `label` (required) -- display label in admin +- `type` (required) -- one of the types above +- `required` -- validation +- `searchable` -- include in full-text search index + +### Common Field Patterns + +**Blog post:** + +```json +"fields": [ + { "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true }, + { "slug": "featured_image", "label": "Featured Image", "type": "image" }, + { "slug": "content", "label": "Content", "type": "portableText", "searchable": true }, + { "slug": "excerpt", "label": "Excerpt", "type": "text" } +] +``` + +**Portfolio project:** + +```json +"fields": [ + { "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true }, + { "slug": "featured_image", "label": "Featured Image", "type": "image", "required": true }, + { "slug": "client", "label": "Client", "type": "string" }, + { "slug": "year", "label": "Year", "type": "string" }, + { "slug": "summary", "label": "Summary", "type": "text", "searchable": true }, + { "slug": "content", "label": "Content", "type": "portableText", "searchable": true }, + { "slug": "gallery", "label": "Gallery", "type": "repeater", "validation": { "subFields": [{ "slug": "image", "label": "Image", "type": "image", "required": true }] } }, + { "slug": "url", "label": "Project URL", "type": "url" } +] +``` + +**Page (minimal):** + +```json +"fields": [ + { "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true }, + { "slug": "content", "label": "Content", "type": "portableText", "searchable": true } +] +``` + +## Block types + +Define `blockTypes` before collections that use a `blocks` field. Each type retains every numbered version and names one active version for new blocks. + +```json +{ + "version": "1", + "blockTypes": [ + { + "slug": "hero", + "label": "Hero", + "currentVersion": 1, + "versions": [ + { + "version": 1, + "fields": [ + { "slug": "heading", "label": "Heading", "type": "string", "required": true }, + { "slug": "image", "label": "Image", "type": "image" } + ] + } + ] + } + ], + "collections": [ + { + "slug": "pages", + "label": "Pages", + "fields": [ + { + "slug": "layout", + "label": "Layout", + "type": "blocks", + "validation": { "allowedTypes": ["hero"], "maxItems": 20 } + } + ] + } + ] +} +``` + +Compatible changes amend the active version. A breaking change creates a new inactive version; deploy renderers for it before activation, then migrate stored blocks explicitly. Removing a type from `allowedTypes` moves it to the server-managed `retiredTypes` list. + +Seeded block values include their type version and stable key: + +```json +{ + "_type": "hero", + "_version": 1, + "_key": "home-hero", + "heading": "Build something useful" +} +``` + +Adding a required blocks field or a positive `minItems` rule to a populated collection is not an additive schema change. Backfill every existing entry before enforcing the requirement. + +Block fields support scalar, text, selection, Portable Text, image, file, and repeater fields. References, JSON, slugs, nested blocks, custom widgets, indexes, uniqueness, and per-subfield localization are not supported inside block definitions. + +## Taxonomies + +Taxonomies are tag/category systems attached to collections. + +```json +{ + "name": "category", + "label": "Categories", + "labelSingular": "Category", + "hierarchical": true, + "collections": ["posts"], + "terms": [ + { "slug": "development", "label": "Development" }, + { "slug": "design", "label": "Design" } + ] +} +``` + +- `hierarchical: true` -- tree structure (like WordPress categories) +- `hierarchical: false` -- flat list (like WordPress tags) +- `collections` -- which collections this taxonomy applies to +- `terms` -- pre-defined terms to create + +## Menus + +Navigation menus, managed from the admin UI. + +```json +{ + "name": "primary", + "label": "Primary Navigation", + "items": [ + { "type": "custom", "label": "Home", "url": "/" }, + { "type": "custom", "label": "About", "url": "/pages/about" }, + { "type": "custom", "label": "Posts", "url": "/posts" } + ] +} +``` + +Menu item types: + +- `custom` -- arbitrary URL +- Content references are resolved at render time + +## Widget Areas + +Named regions where editors can add configurable widgets. + +```json +{ + "name": "sidebar", + "label": "Sidebar", + "description": "Widget area displayed on single post pages", + "widgets": [ + { + "type": "component", + "componentId": "core:search", + "title": "Search" + }, + { + "type": "component", + "componentId": "core:categories", + "title": "Categories" + }, + { + "type": "component", + "componentId": "core:tags", + "title": "Tags" + }, + { + "type": "component", + "componentId": "core:recent-posts", + "title": "Recent Posts", + "settings": { "count": 5, "showDate": true } + }, + { + "type": "component", + "componentId": "core:archives", + "title": "Archives", + "settings": { "type": "monthly", "limit": 6 } + }, + { + "type": "content", + "title": "About", + "content": [ + { + "_type": "block", + "style": "normal", + "children": [{ "_type": "span", "text": "Some rich text content." }] + } + ] + } + ] +} +``` + +### Widget types + +| Type | Description | Key fields | +| ----------- | ------------------------- | ------------------------- | +| `content` | Rich text (Portable Text) | `content` | +| `menu` | Navigation menu | `menuName` | +| `component` | Core or custom component | `componentId`, `settings` | + +### Core widget components + +- `core:search` -- search form +- `core:categories` -- category list with counts +- `core:tags` -- tag cloud +- `core:recent-posts` -- latest posts list +- `core:archives` -- monthly archive links + +## Sections (Reusable Blocks) + +Reusable content blocks that editors can insert via `/section` slash command in the editor. + +```json +{ + "slug": "newsletter-signup", + "title": "Newsletter Signup", + "description": "A call-to-action block for newsletter subscriptions", + "keywords": ["newsletter", "subscribe", "email", "cta"], + "source": "theme", + "content": [ + { + "_type": "block", + "style": "h3", + "children": [{ "_type": "span", "text": "Stay in the loop" }] + }, + { + "_type": "block", + "style": "normal", + "children": [{ "_type": "span", "text": "Get notified when new posts are published." }] + } + ] +} +``` + +## Bylines + +Named author profiles, independent of user accounts. + +```json +{ + "id": "byline-editorial", + "slug": "emdash-editorial", + "displayName": "EmDash Editorial" +} +``` + +Guest bylines: + +```json +{ + "id": "byline-guest", + "slug": "guest-contributor", + "displayName": "Guest Contributor", + "isGuest": true +} +``` + +## Settings + +Site-wide settings: + +```json +"settings": { + "title": "My Blog", + "tagline": "Thoughts on building for the web" +} +``` + +Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`. + +## Content + +Sample content organized by collection slug: + +```json +"content": { + "posts": [ + { + "id": "post-1", + "slug": "hello-world", + "status": "published", + "data": { + "title": "Hello World", + "excerpt": "My first post.", + "featured_image": { + "$media": { + "url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop", + "alt": "Description of image", + "filename": "hello-world.jpg" + } + }, + "content": [ + { + "_type": "block", + "style": "normal", + "children": [{ "_type": "span", "text": "This is the body text." }] + } + ] + }, + "bylines": [ + { "byline": "byline-editorial" } + ], + "taxonomies": { + "category": ["development"], + "tag": ["webdev", "opinion"] + } + } + ], + "pages": [ + { + "id": "about", + "slug": "about", + "status": "published", + "data": { + "title": "About", + "content": [ + { + "_type": "block", + "style": "normal", + "children": [{ "_type": "span", "text": "About this site." }] + } + ] + } + } + ] +} +``` + +### Media references in seed content + +Use `$media` for image fields -- EmDash downloads and stores the image: + +```json +"featured_image": { + "$media": { + "url": "https://images.unsplash.com/photo-xxx?w=1200&h=800&fit=crop", + "alt": "Description", + "filename": "my-image.jpg" + } +} +``` + +For external images without downloading: + +```json +"featured_image": "https://images.unsplash.com/photo-xxx?w=1200" +``` + +### Reference fields in seed content + +Use `$ref:id` format to reference other entries: + +```json +"author": "$ref:byline-editorial" +``` + +### Portable Text in seed content + +Content fields of type `portableText` are arrays of blocks: + +```json +[ + { + "_type": "block", + "style": "normal", + "children": [{ "_type": "span", "text": "A paragraph." }] + }, + { + "_type": "block", + "style": "h2", + "children": [{ "_type": "span", "text": "A heading" }] + }, + { + "_type": "block", + "style": "blockquote", + "children": [{ "_type": "span", "text": "A quote." }] + } +] +``` + +Inline marks (bold, italic, links): + +```json +{ + "_type": "block", + "style": "normal", + "children": [ + { "_type": "span", "text": "This is " }, + { "_type": "span", "text": "bold", "marks": ["strong"] }, + { "_type": "span", "text": " and " }, + { "_type": "span", "text": "italic", "marks": ["em"] } + ] +} +``` + +Block styles: `normal`, `h1`-`h6`, `blockquote`. + +### Draft content + +Set `"status": "draft"` to create unpublished content: + +```json +{ + "id": "post-draft", + "slug": "work-in-progress", + "status": "draft", + "data": { ... } +} +``` + +## Applying Seeds + +The seed at `.emdash/seed.json`, `package.json#emdash.seed`, or `seed/seed.json` is inlined into the build and applied on the first request when the database is empty and the setup wizard hasn't been completed. Existing data is never overwritten. + +Validation runs at apply time. Common errors caught: + +- Image fields with raw URLs (should use `$media`) +- Reference fields with raw IDs (should use `$ref:id`) +- PortableText not an array or missing `_type` +- Type mismatches (string vs number, etc.) + +If the seed is invalid, the first request fails and the error is logged. Restart the dev server after fixing it. + +## Exporting Seeds + +```bash +npx emdash export-seed # Schema only +npx emdash export-seed --with-content # Schema + all content +npx emdash export-seed --with-content=posts,pages # Specific collections +``` diff --git a/potluck/.agents/skills/building-emdash-site/references/site-features.md b/potluck/.agents/skills/building-emdash-site/references/site-features.md new file mode 100644 index 0000000..dffe5c8 --- /dev/null +++ b/potluck/.agents/skills/building-emdash-site/references/site-features.md @@ -0,0 +1,540 @@ +# Site Features + +## Site Settings + +```typescript +import { getSiteSettings, getSiteSettingsWithCacheHint, getSiteSetting } from "emdash"; + +// All settings +const settings = await getSiteSettings(); +settings.title; // "My Site" +settings.tagline; // "A description" +settings.logo?.url; // Resolved media URL +settings.favicon?.url; + +// Single setting +const title = await getSiteSetting("title"); + +// Cached route: register invalidation for settings changes +const { data: cachedSettings, cacheHint } = await getSiteSettingsWithCacheHint(); +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); +``` + +Available keys: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`. + +Use these instead of hard-coding site name, logo, etc. + +## Navigation Menus + +```typescript +import { getMenu, getMenuWithCacheHint, getMenus } from "emdash"; + +// Fetch a named menu +const menu = await getMenu("primary"); + +// List all menus +const menus = await getMenus(); + +// Cached route +const { data: primaryMenu, cacheHint } = await getMenuWithCacheHint("primary"); +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); +``` + +### Rendering a menu + +```astro +--- +import { getMenu } from "emdash"; +const primaryMenu = await getMenu("primary"); +--- + +``` + +### Nested menus (dropdowns) + +```astro +{primaryMenu?.items.map(item => ( +
  • + {item.label} + {item.children.length > 0 && ( + + )} +
  • +))} +``` + +### MenuItem shape + +```typescript +interface MenuItem { + id: string; + label: string; + url: string; // Resolved URL + target?: string; // "_blank" etc. + children: MenuItem[]; +} +``` + +## Taxonomies + +```typescript +import { + getTaxonomyTerms, + getTaxonomyTermsWithCacheHint, + getTerm, + getEntryTerms, + getEntriesByTerm, +} from "emdash"; + +// All terms in a taxonomy (name must match your seed's "name" field exactly) +const categories = await getTaxonomyTerms("category"); +const tags = await getTaxonomyTerms("tag"); + +// Cached taxonomy archive or facet +const { data: cachedCategories, cacheHint } = await getTaxonomyTermsWithCacheHint("category", { + includeCounts: false, +}); +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); + +// Single term by slug +const term = await getTerm("category", "news"); +// { id, name, slug, label, children, count } + +// Terms for a specific entry (use data.id, not entry.id!) +const postCategories = await getEntryTerms("posts", post.data.id, "category"); +const postTags = await getEntryTerms("posts", post.data.id, "tag"); + +// Entries with a specific term +const newsPosts = await getEntriesByTerm("posts", "category", "news"); +``` + +**Important:** The taxonomy name argument must match exactly what your seed defines in `"name"`. The blog seed uses `"category"` and `"tag"` (singular). Using `"categories"` returns empty results with no error. + +**Important:** `getEntryTerms` takes the database ULID (`post.data.id`), not the slug (`post.id`). + +### Displaying post terms + +```astro +--- +const tags = post.data.terms?.tag ?? []; +--- +{tags.map(t => ( + {t.label} +))} +``` + +### 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"; +--- + +``` + +`WidgetArea` registers its widget-area cache hint when Astro's route cache is enabled. + +The `WidgetArea` component automatically renders all widgets in the area (search, categories, tags, recent posts, rich text, etc.) with appropriate HTML and CSS classes. + +### Manual widget rendering + +For more control, use the `getWidgetArea` function: + +```astro +--- +import { getWidgetArea } from "emdash"; +import { PortableText } from "emdash/ui"; + +const sidebar = await getWidgetArea("sidebar"); +--- +{sidebar?.widgets.map(widget => ( +
    + {widget.title &&

    {widget.title}

    } + {widget.type === "content" && widget.content && ( + + )} +
    +))} +``` + +## Search + +### LiveSearch component (instant search) + +```astro +--- +import LiveSearch from "emdash/ui/search"; +--- + +``` + +Customizable CSS classes: + +```astro + +``` + +Theme via CSS variables: + +```css +:root { + --emdash-search-bg: var(--color-bg); + --emdash-search-text: var(--color-text); + --emdash-search-muted: var(--color-muted); + --emdash-search-border: var(--color-border); + --emdash-search-hover: var(--color-surface); + --emdash-search-highlight: var(--color-text); +} +``` + +### Programmatic search + +```typescript +import { search } from "emdash"; + +const results = await search("hello world", { + collections: ["posts", "pages"], + status: "published", + limit: 20, +}); +// { items: SearchResult[], nextCursor? } +``` + +Each item has: `collection`, `id`, `title`, `slug`, `locale`, `snippet` (sanitized HTML with `` highlights), and `score`. + +### Search page + +```astro +--- +import LiveSearch from "emdash/ui/search"; +import Base from "../layouts/Base.astro"; + +const query = Astro.url.searchParams.get("q") || ""; +--- + +

    Search

    + + +``` + +### Keyboard shortcut + +Add Cmd+K / Ctrl+K to focus search: + +```html + +``` + +### Search prerequisites + +Search requires per-collection enablement: + +1. In admin: Edit Content Type -> check "Search" in Features +2. Mark fields as `"searchable": true` in the seed file +3. Only searchable fields of searchable collections are indexed + +## SEO Meta + +> [!IMPORTANT] +> On server-rendered content pages that fetch the entry with `getEmDashEntry()` +> and render ``, EmDash automatically applies the SEO panel's +> description, image, canonical URL, and noindex setting. The panel title +> supplies the social and JSON-LD title contributions. The panel data uses the +> entry query the page already runs, so it adds no additional query. +> +> `` cannot set the document ``, so use `getSeoMeta()` for +> the title. Prerendered pages, pages without `<EmDashHead>`, hand-rolled query +> paths, and multi-entry collection results do not receive this overlay. Use +> `getSeoMeta()` or the raw SEO data on those paths. + +Use `getSeoMeta()` when the template needs to set `<title>` or the page does not +receive the automatic overlay: + +```typescript +import { getSeoMeta } from "emdash"; + +const seo = getSeoMeta(post, { + siteTitle: "My Blog", + siteUrl: Astro.url.origin, + path: `/posts/${slug}`, + defaultOgImage: featuredImageUrl, // Optional fallback +}); +``` + +Use in your layout's `<head>`: + +```astro +<title>{seo.title} +{seo.description && } +{seo.canonical && } +{seo.ogImage && } +{seo.robots && } +``` + +### Custom title and description defaults + +Pass computed fallbacks through `defaultTitle` and `defaultDescription`. Values +set in the SEO panel take precedence over these defaults: + +```ts +import { getSeoMeta } from "emdash"; + +const seo = getSeoMeta(post, { + defaultTitle: `${post.data.title}: A practical guide`, + defaultDescription: `Read ${post.data.title} on My Blog.`, +}); +``` + +## Comments + +Built-in comments system: + +```astro +--- +import { Comments, CommentForm } from "emdash/ui/comments"; +--- + + +``` + +Comments are enabled per-collection in the seed: `"commentsEnabled": true`. + +## Page Contributions (Plugin Head/Body Injection) + +Plugins can inject content into the `` and `` 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 }, +}); +--- + + + + + + + + + + + +``` + +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 && ( + {post.data.byline.displayName} +)} + +{/* All credits (includes roleLabel for co-authors, guest essays, etc.) */} +{post.data.bylines?.map(credit => ( + + {credit.byline.displayName} + {credit.roleLabel && ({credit.roleLabel})} + +))} +``` + +- `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 + + +``` + +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, +}); +--- + + + + + + {title} + {description && } + + + + +
    + +
    +
    + +
    +
    + +
    + + + +``` diff --git a/potluck/.agents/skills/creating-plugins/SKILL.md b/potluck/.agents/skills/creating-plugins/SKILL.md new file mode 100644 index 0000000..dc2ea80 --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/SKILL.md @@ -0,0 +1,134 @@ +--- +name: creating-plugins +description: Create EmDash CMS plugins with sandboxed hooks, routes, storage, content and media APIs, MCP tools, and declarative admin UI, or native React and Astro extensions. Use when scaffolding or implementing an EmDash plugin. +--- + +# Creating EmDash plugins + +Build against the API that reaches the intended execution mode. Source types and production-boundary tests take precedence over examples in this skill when they disagree. + +## Choose a format + +| Format | Runtime source | Admin UI | Distribution | +| --------- | --------------------------------------------------- | --------------------------------------------------------- | ---------------------------- | +| Sandboxed | `src/plugin.ts` default-exports a `SandboxedPlugin` | Block Kit pages, widgets, saved-entry panels, and actions | Plugin CLI and registry | +| Native | `definePlugin()` / `createPlugin()` | React, Block Kit, and Astro components | Trusted site dependency only | + +Use a sandboxed plugin unless the feature needs host-process access, React admin code, Astro rendering components, raw page fragments, or custom Portable Text block definitions. Native plugins run with the site's authority and cannot be installed from the registry. + +## Scaffold a sandboxed plugin + +```sh +pnpm dlx @emdash-cms/plugin-cli init my-plugin +cd my-plugin +pnpm install +pnpm run test +``` + +The manifest is the identity and trust contract. Runtime hooks and routes live in `src/plugin.ts`; the CLI generates descriptors, manifests, and bundles. Do not create a separate descriptor factory or `sandbox-entry.ts`. + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; + +const plugin: SandboxedPlugin = { + hooks: { + "content:afterSave": async (event, ctx) => { + ctx.log.info("Content saved", { id: event.content.id }); + }, + }, +}; + +export default plugin; +``` + +Import authoring types from `emdash/plugin` with `import type`. Value imports are limited to lightweight helpers such as `pluginRoute()` and `pluginResponse()`, which the CLI bundles. Sandboxed runtime code can use Web APIs but not Node.js built-ins. + +## Declare access + +Declare every host API in `emdash-plugin.jsonc`. Adding authority, exposing a route publicly, or adding MCP tools requires renewed administrator approval. + +| Capability | Grants | +| -------------------------------- | -------------------------------------------------------------------------- | +| `schema:read` | Public collection and field definitions | +| `admin.editor-draft:read` | Selected unsaved field values after an explicit editor interaction | +| `admin.editor-draft:patch` | Host-validated unsaved field changes proposed for editor review | +| `content:read` | Content identity, translations, and published public URLs | +| `content:revisions:read` | Retained revision data; implies content read | +| `content:write` | Create, update, delete, and translation creation; implies read | +| `content:publish` | Revision-fenced publish, unpublish, schedule, and unschedule; implies read | +| `content:restore` | Revision-fenced reads and restoration of trashed content | +| `hooks.content-policy:register` | Pre-publish, pre-schedule, and pre-unpublish policy hooks | +| `taxonomies:read` | Taxonomy definitions, terms, and entry assignments | +| `taxonomies:write` | Term creation and assignment deltas; implies read | +| `redirects:read` | Versioned redirect inspection | +| `redirects:write` | Versioned redirect creation, update, and deletion; implies read | +| `comments:read` | Stored non-trashed comments and their personal data | +| `comments:moderate` | Expected-status moderation; implies read | +| `media:read` | Ready-media metadata and authenticated asset URLs | +| `media:bytes:read` | Bounded media bytes and content hashes | +| `media:metadata:write` | Alt text, caption, and focal-point updates | +| `media:write` | Upload and delete; implies media read | +| `network:request` | `ctx.http.fetch()` restricted to `allowedHosts` | +| `network:request:unrestricted` | `ctx.http.fetch()` without a host list | +| `users:read` | User directory lookup; also required by comment hooks | +| `email:send` | Email delivery when a transport is configured | +| `hooks.email-transport:register` | Exclusive `email:deliver` hook | +| `hooks.email-events:register` | Email before/after hooks | +| `hooks.page-fragments:register` | Trusted-only page fragments; excluded from sandbox registration | + +Settings, KV, declared storage, logging, and cron scheduling are plugin-scoped and need no capability. Use `ctx.settings` for user configuration, `ctx.kv` for internal key-value state, and declared `ctx.storage.` for queryable records. + +Read the focused reference for the API being used: + +- [Content, schema, translations, and publication](./references/content.md) +- [Taxonomies and redirects](./references/taxonomies-and-redirects.md) +- [Comments](./references/comments.md) +- [Media](./references/media.md) +- [Storage, KV, and encrypted settings](./references/storage.md) + +## Routes, HTTP, and MCP + +Routes are private by default and require authentication, their declared RBAC permission, token scope or CSRF as appropriate. Public routes are internet-facing and require explicit install consent. + +Undeclared routes keep the legacy JSON/query envelope. Declare methods and body modes for host-enforced parsing. Use `pluginResponse()` only on a route that declares a raw response. Outbound `ctx.http.fetch()` responses and declared route bodies are buffered and bounded. + +MCP tools reference private JSON routes with explicit permissions and Zod input schemas. Mark difficult-to-reverse operations `destructive: true`. Read [API routes and MCP tools](./references/api-routes.md). + +## Hooks + +Sandboxed hooks enter the same priority, dependency, timeout, error-policy, enablement, exclusive-provider, and capability pipeline as trusted hooks. Publication policy hooks identify action origin and actor without granting publication authority. Comment moderation invoked through `ctx.comments` reports plugin origin and runs the normal after-hook once. Read [Hooks](./references/hooks.md). + +## Declarative admin UI + +Sandboxed pages and widgets return validated Block Kit. Structured links use host-resolved targets; `routeCtx.ui` carries host-attested locale, direction, and surface. External images require matching network authority. + +Saved-entry panels and actions point to private routes. Ordinary panel load receives only host-reloaded saved identity and version. Add `admin.editor-draft:read` or `admin.editor-draft:patch` plus extension-level collection and field selectors when an explicit interaction must receive selected unsaved fields or propose an atomic whole-field patch. Patch does not imply read. The host previews accepted patches, marks the form dirty, and never saves them automatically. Read saved content through capability-gated `ctx.content`. + +Declarative field widgets currently compose supported Block Kit elements into a JSON value. Custom Portable Text blocks and Astro render components remain native-only. Read [Admin UI](./references/admin-ui.md), [Block Kit](./references/block-kit.md), and [Portable Text blocks](./references/portable-text-blocks.md). + +## Runner parity + +Cloudflare Worker Loader and Node/workerd execute the same bundle behind a plugin-scoped bridge. Both return buffered WHATWG responses from `ctx.http.fetch()` with binary bytes preserved and decoded request/response bodies limited to 8 MiB. Write against exported types, not extra methods found in one wrapper. + +Read [Sandbox boundaries](./references/sandbox-boundaries.md) before designing around an API not listed here. + +## Test the production boundary + +Use `createPluginTestHost()` for fast hook, route, manifest, capability, KV, settings, and storage transport tests. Use `createPluginRuntimeTestHost()` when the test must exercise real content actions, plugin activation, media, comments, redirects, scheduling, restart, authorization, CSRF, caching, Block Kit validation, or saved-entry extensions. + +Runtime fixtures establish state without firing hooks. Runtime actions call production boundaries; inspectors read observable state. Queue outbound HTTP responses with `host.http.respond()` and inspect requests with `host.http.requests()`. Dispose every host after use. Add Node/workerd parity only for runner-sensitive behavior. + +## References + +- [Content, schema, translations, and publication](./references/content.md) +- [Taxonomies and redirects](./references/taxonomies-and-redirects.md) +- [Comments](./references/comments.md) +- [Media](./references/media.md) +- [Hooks](./references/hooks.md) +- [Storage, KV, and encrypted settings](./references/storage.md) +- [Admin UI and field widgets](./references/admin-ui.md) +- [API routes and MCP tools](./references/api-routes.md) +- [Block Kit](./references/block-kit.md) +- [Portable Text blocks](./references/portable-text-blocks.md) +- [Sandbox boundaries](./references/sandbox-boundaries.md) +- [Publishing](./references/publishing.md) diff --git a/potluck/.agents/skills/creating-plugins/references/admin-ui.md b/potluck/.agents/skills/creating-plugins/references/admin-ui.md new file mode 100644 index 0000000..7196be8 --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/admin-ui.md @@ -0,0 +1,211 @@ +# Admin UI and field widgets + +Sandboxed plugins return declarative Block Kit from a private route. Native plugins may instead ship React components. Keep these paths separate: sandboxed plugin JavaScript never runs in the browser. + +## Sandboxed pages and dashboard widgets + +Declare navigation and widget cards in `emdash-plugin.jsonc`: + +```jsonc title="emdash-plugin.jsonc" +{ + "admin": { + "pages": [ + { "path": "/settings", "label": "Settings", "icon": "settings" }, + { "path": "/reports", "label": "Reports", "icon": "chart" }, + ], + "widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }], + }, +} +``` + +Pages mount at `/_emdash/admin/plugins//`. Widget sizes are `full`, `half`, and `third`. + +Any sandboxed plugin that declares a page or widget must define an `admin` route. The admin sends a `page_load`, `block_action`, or `form_submit` interaction as `routeCtx.input`: + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; +import type { BlockResponse } from "@emdash-cms/blocks"; +import { z } from "zod"; + +const interactionSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("page_load"), page: z.string() }), + z.object({ + type: z.literal("block_action"), + action_id: z.string(), + block_id: z.string().optional(), + value: z.unknown().optional(), + }), + z.object({ + type: z.literal("form_submit"), + action_id: z.string(), + block_id: z.string().optional(), + values: z.object({ enabled: z.boolean() }), + }), +]); + +function settingsForm(enabled: boolean): BlockResponse { + return { + blocks: [ + { type: "header", text: "Settings" }, + { + type: "form", + block_id: "settings", + fields: [ + { type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled }, + ], + submit: { action_id: "save", label: "Save" }, + }, + ], + }; +} + +const plugin: SandboxedPlugin = { + routes: { + admin: { + permission: "plugins:manage", + handler: async (routeCtx, ctx) => { + const parsed = interactionSchema.safeParse(routeCtx.input); + if (!parsed.success) return { blocks: [] }; + const interaction = parsed.data; + if (interaction.type === "form_submit" && interaction.action_id === "save") { + await ctx.settings.set("enabled", interaction.values.enabled === true); + return { + ...settingsForm(interaction.values.enabled === true), + toast: { type: "success", message: "Settings saved" }, + }; + } + + const enabled = (await ctx.settings.get("enabled")) ?? false; + return settingsForm(enabled); + }, + }, + }, +}; + +export default plugin; +``` + +Validate interactions before production side effects; `routeCtx.input` is `unknown`. Read [Block Kit](./block-kit.md) for exact interaction, block, and element shapes. + +The plugin CLI preserves `admin.settingsSchema` in the registry manifest and generated descriptor, so the host can generate a settings form. Both sandbox bridges route `ctx.settings` through the same options records as that form. Read a generated setting with `ctx.settings.get("")`; writes, deletes, list operations, and revision-based operations use the same namespace on Cloudflare and Node/workerd. + +The `secret` settings field is write-only in the admin response and encrypted before persistence. The site must provide `EMDASH_ENCRYPTION_KEY`; missing, wrong, or tampered key material fails closed. Keep the encryption-key list with database backups. Existing `ctx.kv.get("settings:")` reads remain compatible throughout EmDash 1.x. + +## Sandboxed saved-entry extensions + +Declare saved-entry panels and actions in `emdash-plugin.jsonc`: + +```jsonc title="emdash-plugin.jsonc" +{ + "admin": { + "editorPanels": [ + { + "id": "health", + "title": "Content health", + "route": "editor/health", + "collections": ["posts"], + "draft": { + "read": { "translatable": true }, + "patch": { "fields": ["title", "excerpt"] }, + }, + }, + ], + "editorActions": [ + { + "id": "repair", + "label": "Repair metadata", + "route": "editor/repair", + "placement": "overflow", + "style": "danger", + "confirm": { + "title": "Repair?", + "text": "This changes the saved entry.", + "confirm": "Repair", + "deny": "Cancel", + }, + }, + ], + }, +} +``` + +Every referenced route must be private. EmDash reloads the saved entry and checks ownership plus the route permission before invoking it. `routeCtx.ui.entry` contains only the canonical collection, ID, locale, and version. + +Panels start collapsed. `panel_load` never includes draft values. After an explicit `block_action`, `form_submit`, or `editor_action`, `admin.editor-draft:read` can attach only the fields selected by the extension's `draft.read` declaration. `fields` selects explicit slugs and `translatable: true` selects current schema fields marked translatable. Draft declarations require explicit collection scope. + +`admin.editor-draft:patch` permits a separate `draft.patch` field selector and does not imply read. Return `patch: { type: "editor-draft-patch", operations }` with whole-field `set` or `clear` operations. The host rejects the complete patch on an unknown, forbidden, invalid, unsupported, oversized, or stale operation. Accepted patches receive a host-rendered preview, update the form atomically, mark it dirty, and remain unsaved. A response may contain a toast and one terminal effect: patch, refresh, or navigation. + +Use `createPluginRuntimeTestHost().admin` to exercise this boundary. `captureEditorDraft()` creates a saved-entry draft request, the existing panel/action helpers invoke the production route, and `applyEditorDraftPatch()` applies only a current response through the host validator. + +## Sandboxed declarative field widgets + +Core and the admin contain a declarative field-widget path. Declare the widget in the registry manifest: + +```jsonc title="emdash-plugin.jsonc" +{ + "admin": { + "fieldWidgets": [ + { + "name": "event-picker", + "label": "Event", + "fieldTypes": ["json"], + "elements": [ + { "type": "text_input", "action_id": "eventId", "label": "Event ID" }, + { "type": "toggle", "action_id": "featured", "label": "Featured" }, + ], + }, + ], + }, +} +``` + +A schema field selects it with `widget: "pluginId:widgetName"`. The editor stores an object keyed by each element's `action_id`. Use a `json` field for this object. The manifest schema accepts other compatible field types, but the repository has no end-to-end test proving that the composed object saves through them. + +The current field-widget renderer supports: + +- `text_input` +- `number_input` +- `toggle` +- `select` +- `media_picker` + +Other Block Kit element types display an unsupported-element message in this surface. + +`emdash-plugin.jsonc` accepts `admin.fieldWidgets`, and the plugin CLI carries the definitions through the bundle manifest and generated descriptor for registry installation. The artifact round-trip is covered by plugin CLI, shared manifest, and plugin-test tests. The browser E2E fixture still tests a native React color picker rather than a registry-installed declarative widget, so verify the real editor render and value persistence for the chosen elements. + +The sandbox admin route receives `routeCtx.ui` with the host-attested admin locale, text direction, and surface. Use it to select localized text in a runtime Block Kit response. Labels in manifest metadata remain static strings; registry plugins do not hand translation catalogs to the host. + +## Native React pages, widgets, and fields + +Native plugins may set `admin.entry` and export React components: + +```typescript title="src/admin.tsx" +export const pages = { + "/settings": SettingsPage, +}; + +export const widgets = { + status: StatusWidget, +}; + +export const fields = { + picker: ColorPickerField, +}; +``` + +The plugin definition points to the entry and declares its surfaces: + +```typescript +definePlugin({ + id: "color", + version: "1.0.0", + admin: { + entry: "@my-org/plugin-color/admin", + pages: [{ path: "/settings", label: "Settings" }], + widgets: [{ id: "status", title: "Status", size: "half" }], + fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }], + }, +}); +``` + +Native admin code must follow the repository's Kumo, localization, accessibility, and RTL rules. It runs with the site's authority and is not registry-installable. diff --git a/potluck/.agents/skills/creating-plugins/references/api-routes.md b/potluck/.agents/skills/creating-plugins/references/api-routes.md new file mode 100644 index 0000000..103dc75 --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/api-routes.md @@ -0,0 +1,245 @@ +# API routes and MCP tools + +Sandboxed plugin routes are mounted at `/_emdash/api/plugins//`. Define them in the typed default export from `src/plugin.ts`. + +## Define a route + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; +import { z } from "zod"; + +const submissionInput = z.object({ + formId: z.string().min(1), + limit: z.coerce.number().int().min(1).max(100).default(50), +}); + +const plugin: SandboxedPlugin = { + routes: { + submissions: { + permission: "content:read", + handler: async (routeCtx, ctx) => { + const parsed = submissionInput.safeParse(routeCtx.input); + if (!parsed.success) return { ok: false, error: "INVALID_INPUT" }; + + const result = await ctx.storage.submissions.query({ + where: { formId: parsed.data.formId }, + limit: parsed.data.limit, + }); + return { ok: true, ...result }; + }, + }, + }, +}; + +export default plugin; +``` + +The handler receives two arguments: + +```typescript +interface SandboxedRouteContext { + input: unknown; + request: { + url: string; + method: string; + headers: Record; + }; + requestMeta?: { + ip: string | null; + userAgent: string | null; + referer: string | null; + geo: { country: string | null; region: string | null; city: string | null } | null; + }; + user?: UserInfo; +} +``` + +`routeCtx.request` is a serialized record in both sandbox runners, not a WHATWG `Request`. Header keys are lowercased. Read parsed request data from `routeCtx.input`; the body has already been consumed by the host. + +The published authoring type currently declares `requestMeta` as `unknown`, although both runners send the normalized shape shown above. Narrow it before reading fields when TypeScript cannot infer the shape. + +## Input sources and validation + +Undeclared routes keep the original input contract: the host parses JSON for `POST`, `PUT`, and +`PATCH`, and parses the query string for `GET`, `HEAD`, and `DELETE`. Repeated query keys become +arrays. Validate the resulting `unknown` value inside the handler. + +Declare `request.body` as `none`, `json`, `text`, `bytes`, or `form-data` to select a parser. Bodies +are buffered with a 1 MiB default maximum; `maxBytes` can raise the limit to 8 MiB. Use +`pluginRoute()` from `emdash/plugin` to infer `PluginRouteQuery`, `string`, `Uint8Array`, or +`PluginFormData` from the mode. JSON remains `unknown` for validation. + +Form data accepts multipart and URL-encoded requests. The multipart limits are 100 parts, 1 MiB per +part, and 255 UTF-8 bytes for a safe filename. The total request must also fit the route body limit. + +Declare request header names explicitly in `request.headers`. Only those safe headers reach the +handler. Credentials, cookies, Cloudflare Access headers, proxy authorization, `Set-Cookie`, and +the EmDash CSRF header cannot be declared and never cross the sandbox boundary. + +The plugin CLI probe currently does not retain a route entry's `input` schema, so do not rely on route-level Zod validation for a built sandboxed plugin. An MCP tool still requires its own Zod input schema. + +## Authentication, permission, and CSRF + +Routes are private unless they set `public: true`. + +A private route requires: + +- an authenticated session, or a token with the `admin` scope; +- the route's declared EmDash RBAC `permission`, defaulting to `plugins:manage`; +- `X-EmDash-Request: 1` for cookie-authenticated calls, for every HTTP method. + +The host resolves these checks before invoking the plugin. `routeCtx.user` then contains the authenticated caller for a user-bound request: + +```typescript +interface UserInfo { + id: string; + email: string; + name: string | null; + role: number; + createdAt: string; +} +``` + +Caller identity is not gated by `users:read`; it identifies the current authorized caller. `ctx.users` is a directory lookup and does require `users:read`. `routeCtx.user` is absent on public routes and on machine-token calls without a bound user. + +A public route skips authentication, permission, and token-scope checks. It is internet-facing, so validate input, check the intended HTTP method, and verify webhook signatures or shared tokens where applicable. + +Public exposure is reviewed during installation. Adding a public route or changing a private route +to public requires approval again on update. + +## HTTP methods + +The route name selects one handler. Declare `methods` to have the host reject other methods with +`405 Method Not Allowed` and an `Allow` header before the handler runs: + +```typescript +methods: ["POST"], +handler: async (routeCtx, ctx) => { + // Validate input, then mutate. +}, +``` + +Routes without `methods` remain method-agnostic. Check the method inside legacy handlers that mutate +state, or add a declaration. + +## Results and errors + +Return a JSON-serializable value. The HTTP endpoint wraps it in EmDash's `{ success: true, data }` envelope. + +Return a stable application-level error object for expected validation and domain failures. Throw only for unexpected failures, and keep exception messages free of credentials, personal data, paths, and stack traces. + +Declare `response: "raw"` for unwrapped responses. The handler must return `pluginResponse()` from +`emdash/plugin` with a text or `Uint8Array` body. Raw response bodies are buffered up to 8 MiB. +The response header allowlist is `Accept-Ranges`, `Content-Disposition`, `Content-Encoding`, +`Content-Language`, `Content-Range`, `Content-Type`, `ETag`, `Last-Modified`, `Location`, and +`Retry-After`. The host removes other plugin-supplied headers, adds security headers, and applies the +route-owned cache policy. + +Raw routes cannot return active same-origin types, including HTML, JavaScript or ECMAScript, XHTML, +SVG, XML, CSS, WebAssembly, `multipart/related`, and `multipart/x-mixed-replace`. A raw route also +cannot back an MCP tool. Do not return or throw a WHATWG `Response`; it is not the portable response +wire. + +## Public caching + +Core accepts `cacheControl` on a public route: + +```typescript +routes: { + catalog: { + public: true, + cacheControl: "public, max-age=60, stale-while-revalidate=300", + handler: async () => ({ items: [] }), + }, +}, +``` + +The value is applied only to successful public `GET` and `HEAD` responses. Private responses, errors, and other methods remain `private, no-store`. The plugin CLI carries `cacheControl` through the probe, bundle manifest, registry artifact, and generated descriptor. + +## Request metadata + +Both runners send the same normalized metadata: + +- `ip`: a trusted client address when the platform or operator configured a trusted proxy header, otherwise `null`; +- `userAgent`: the `User-Agent` value, otherwise `null`; +- `referer`: the `Referer` value, otherwise `null`; +- `geo`: Cloudflare country, region, and city when available, otherwise `null`. + +Do not treat `userAgent`, `referer`, or geographic values as authenticated identity. Use `routeCtx.user` for the caller. + +## Content reads + +With `content:read`, both sandbox runners match the trusted read contract. `ctx.content.get()` and `ctx.content.list()` return content identity, slug, status, locale, data, created/updated/published/scheduled timestamps, and SEO metadata when enabled. + +`list()` accepts `limit`, `cursor`, `where`, and `orderBy`. Field filters, status filters, ordering, and cursor pagination reach the host repository on both runners; they are not evaluated inside the plugin isolate. `getTranslations()` and `getPublicUrl()` use the same `content:read` authority. Collection discovery uses `ctx.schema` with `schema:read`. Read [Content, schema, translations, and publication](./content.md) for the complete contract. + +## Publication actions + +With `content:publish`, call `getVersioned()` before `publish()`, `unpublish()`, `schedule()`, or `unschedule()`, then pass the returned `_rev`. Each successful mutation returns the item and its next `_rev`. `content:restore` separately provides `getTrashedVersioned()` and `restore()` without granting ordinary content reads. These actions execute through the host runtime, including policy and after-hooks; a stale revision or recursive action rejects without changing the entry. + +## Redirects + +Declare `redirects:read` to list redirect rules with cursor pagination and read one rule with its opaque `_rev`. Declare `redirects:write` to create, update, or delete rules; it implies read access and authorizes the plugin to change where visitors are sent. + +Pass `_rev` back unchanged for `update()` and `delete()`. A stale value fails with `CONFLICT`; re-read the current rule before retrying. The revision tracks redirect configuration, not visitor hit counting. Redirect writes use the host redirect handlers, including path-pattern, destination-parameter, duplicate-source, and terminal-status validation. Self-loop and multi-hop-loop validation runs when a rule is created or its source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. Plugin input cannot set the host-owned `auto` marker. + +## External HTTP responses + +`ctx.http.fetch()` returns a buffered WHATWG `Response` in both sandbox runners. `ok`, `status`, `statusText`, `headers`, `url`, `redirected`, `text()`, `json()`, `arrayBuffer()`, `blob()`, and `clone()` use the standard Web API and preserve the same values across runners. + +Request and response bodies are each limited to 8 MiB of decoded bytes. The bridge enforces the limit while reading the body rather than trusting `Content-Length`. Responses are buffered rather than streamed to plugin code. + +## Expose a route as an MCP tool + +MCP exposure is explicit. The following tool calls the private route as `__createEvent`: + +```typescript title="src/plugin.ts" +import type { SandboxedPlugin } from "emdash/plugin"; +import { z } from "zod"; + +const createEventInput = z.object({ + title: z.string().min(1), + startsAt: z.string().datetime(), +}); + +const plugin: SandboxedPlugin = { + routes: { + "events/create": { + permission: "content:create", + handler: async (routeCtx) => { + const parsed = createEventInput.safeParse(routeCtx.input); + if (!parsed.success) return { ok: false, error: "INVALID_EVENT" }; + return { ok: true, id: crypto.randomUUID() }; + }, + }, + }, + mcp: { + tools: { + createEvent: { + description: "Create a calendar event requested by the user.", + route: "events/create", + input: createEventInput, + output: z.object({ ok: z.boolean(), id: z.string().optional() }), + destructive: false, + }, + }, + }, +}; + +export default plugin; +``` + +An MCP tool must: + +- use a tool name containing only letters, digits, `_`, or `-`; +- reference an existing private route; +- reference a route with an explicit valid `permission`; +- reference a JSON route, not a route with `response: "raw"`; +- declare an input Zod schema; +- set `destructive: true` for deletion, overwrite, publishing, charging, or another difficult-to-reverse action. + +The optional output schema becomes structured MCP output. The bundle converts both schemas to JSON Schema. + +Installation and updates show the exact MCP tools for consent. Adding a tool or changing a route from private to public requires fresh approval. After installation, an administrator separately enables plugin MCP tools. A caller then needs the route permission and either the `mcp:tools` scope or `mcp:tools:`. + +The production core parser, shared plugin-types parser, plugin CLI artifact, and generated descriptor preserve MCP declarations and route permission/cache metadata. `@emdash-cms/plugin-test` exposes the parsed manifest so tests can assert that transport. Its `invokeRoute()` still bypasses the HTTP catch-all, so use the host for plugin execution and bridge behavior, not route authorization, response caching, MCP registration, or consent behavior. diff --git a/potluck/.agents/skills/creating-plugins/references/block-kit.md b/potluck/.agents/skills/creating-plugins/references/block-kit.md new file mode 100644 index 0000000..d5997f6 --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/block-kit.md @@ -0,0 +1,584 @@ +# Block Kit + +Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities. + +Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins. + +Native plugins also use Block Kit elements for [Portable Text block editing fields](./portable-text-blocks.md). Plugin CLI and registry packages cannot register Portable Text block types. + +## How It Works + +1. User navigates to plugin admin page +2. Admin sends `page_load` interaction to plugin's admin route +3. Plugin returns `BlockResponse` with array of blocks +4. Admin renders blocks using `BlockRenderer` +5. User interacts (button click, form submit) → interaction sent back +6. Plugin returns new blocks + +```typescript +import type { BlockInteraction } from "@emdash-cms/blocks"; + +routes: { + admin: { + handler: async (ctx) => { + // EmDash parses the request body once and exposes it as ctx.input; + // read it directly rather than ctx.request.json() (the body is consumed). + // BlockInteraction is the discriminated union of page_load, + // block_action, and form_submit payloads. + const interaction = ctx.input as BlockInteraction; + + if (interaction.type === "page_load") { + return { + blocks: [ + { type: "header", text: "My Plugin Settings" }, + { + type: "form", + block_id: "settings", + fields: [ + { type: "text_input", action_id: "api_url", label: "API URL" }, + { type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true }, + ], + submit: { label: "Save", action_id: "save" }, + }, + ], + }; + } + + if (interaction.type === "form_submit" && interaction.action_id === "save") { + await ctx.kv.set("settings", interaction.values); + return { + blocks: [/* updated blocks */], + toast: { message: "Settings saved", type: "success" }, + }; + } + }, + }, +} +``` + +## Block Types + +| Type | Description | +| ----------- | --------------------------------------------------- | +| `header` | Large bold heading | +| `section` | Text with optional accessory element | +| `divider` | Horizontal rule | +| `fields` | Two-column label/value grid | +| `table` | Data table with formatting, sorting, pagination | +| `actions` | Horizontal row of buttons and controls | +| `stats` | Dashboard metric cards with trend indicators | +| `form` | Input fields with conditional visibility and submit | +| `image` | Block-level image with alt text and optional title | +| `context` | Small muted help text | +| `columns` | 2-3 column layout with nested blocks | +| `chart` | Charts (timeseries line/bar, pie, custom ECharts) | +| `code` | Syntax-highlighted code block | +| `meter` | Progress/quota meter bar | +| `banner` | Info, warning, or error inline messages | +| `empty` | Empty state with optional command and actions | +| `accordion` | Collapsible section containing nested blocks | + +## Element Types + +| Type | Description | +| -------------- | --------------------------------------------------------- | +| `button` | Action button with optional confirmation dialog | +| `link` | Host-resolved navigation that does not dispatch an action | +| `text_input` | Single-line or multiline text input | +| `number_input` | Numeric input with min/max | +| `select` | Dropdown select | +| `toggle` | On/off switch | +| `secret_input` | Masked input for API keys and tokens | +| `checkbox` | Multi-select checkboxes | +| `radio` | Single-select radio buttons | +| `date_input` | Date picker | +| `combobox` | Searchable dropdown select | +| `repeater` | Array of records with scalar sub-fields | +| `media_picker` | Media-library picker that stores the asset URL | + +## Block Syntax + +### Header + +```json +{ "type": "header", "text": "Settings" } +``` + +### Section + +```json +{ + "type": "section", + "text": "Configure your plugin settings below.", + "accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" } +} +``` + +### Divider + +```json +{ "type": "divider" } +``` + +### Fields + +```json +{ + "type": "fields", + "fields": [ + { "label": "Status", "value": "Active" }, + { "label": "Last Sync", "value": "2 hours ago" } + ] +} +``` + +### Stats + +```json +{ + "type": "stats", + "items": [ + { "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" }, + { "label": "Active", "value": "567" } + ] +} +``` + +- `items` — the array key is `items`, not `stats` +- `trend` — `"up"`, `"down"`, or `"neutral"`; renders an arrow icon next to the value +- `description` — secondary line beneath the value, for context such as "+12% vs last week" + +### Table + +```json +{ + "type": "table", + "columns": [ + { "key": "name", "label": "Name" }, + { "key": "status", "label": "Status" }, + { "key": "date", "label": "Date" } + ], + "rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }], + "page_action_id": "browse_items", + "empty_text": "No items yet." +} +``` + +- `page_action_id` — required. The admin sends it as the `block_action` id when the user sorts a column or pages through results. +- `empty_text` — shown in place of the table when `rows` is empty +- `next_cursor` — set it to render a "Load more" control + +### Actions + +```json +{ + "type": "actions", + "elements": [ + { "type": "button", "label": "Save", "action_id": "save", "style": "primary" }, + { "type": "button", "label": "Cancel", "action_id": "cancel" } + ] +} +``` + +### Form + +```json +{ + "type": "form", + "block_id": "settings", + "fields": [ + { "type": "text_input", "action_id": "name", "label": "Name" }, + { "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 }, + { + "type": "select", + "action_id": "theme", + "label": "Theme", + "options": [ + { "label": "Light", "value": "light" }, + { "label": "Dark", "value": "dark" } + ] + }, + { "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true }, + { "type": "secret_input", "action_id": "api_key", "label": "API Key" } + ], + "submit": { "label": "Save", "action_id": "save_settings" } +} +``` + +### Columns + +```json +{ + "type": "columns", + "columns": [ + [ + { "type": "header", "text": "Usage" }, + { "type": "meter", "label": "Storage used", "value": 65 } + ], + [ + { "type": "header", "text": "Activity" }, + { "type": "context", "text": "Last sync 2 hours ago" } + ] + ] +} +``` + +- `columns` — 2 or 3 columns, each one an array of blocks + +### Chart (Timeseries) + +```json +{ + "type": "chart", + "config": { + "chart_type": "timeseries", + "series": [ + { + "name": "Requests", + "data": [ + [1709596800000, 42], + [1709600400000, 67], + [1709604000000, 53] + ], + "color": "#086FFF" + }, + { + "name": "Errors", + "data": [ + [1709596800000, 2], + [1709600400000, 5], + [1709604000000, 1] + ] + } + ], + "x_axis_name": "Time", + "y_axis_name": "Count", + "style": "line", + "gradient": true, + "height": 300 + } +} +``` + +- `series[].data` — array of `[timestamp_ms, value]` tuples +- `series[].color` — hex color (optional, auto-assigned from Kumo palette) +- `style` — `"line"` (default) or `"bar"` +- `gradient` — fill gradient beneath lines (default false) +- `height` — chart height in pixels (default 350) + +### Chart (Custom) + +For pie charts, gauges, or any ECharts visualization: + +```json +{ + "type": "chart", + "config": { + "chart_type": "custom", + "options": { + "series": [ + { + "type": "pie", + "data": [ + { "value": 335, "name": "Published" }, + { "value": 234, "name": "Draft" }, + { "value": 120, "name": "Scheduled" } + ] + } + ] + }, + "height": 300 + } +} +``` + +- `options` — raw ECharts option object passed through to `chart.setOption()` + +### Code + +```json +{ + "type": "code", + "code": "const greeting = \"Hello!\";\nconsole.log(greeting);", + "language": "ts" +} +``` + +- `language` — `"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`) + +### Meter + +```json +{ + "type": "meter", + "label": "Storage used", + "value": 65, + "custom_value": "6.5 GB / 10 GB" +} +``` + +- `value` — numeric value (default range 0-100) +- `max` / `min` — custom range (defaults to 0-100) +- `custom_value` — display string instead of percentage (e.g. "750 / 1,000") + +### Banner + +```json +{ + "type": "banner", + "title": "API key invalid", + "description": "Please check your API key in settings.", + "variant": "error" +} +``` + +- `variant` — `"default"` (info, default), `"alert"` (warning), or `"error"` +- At least one of `title` or `description` is required + +### Empty + +```json +{ + "type": "empty", + "title": "No submissions", + "description": "New submissions appear here.", + "command_line": "pnpm run seed", + "size": "base", + "actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }] +} +``` + +### Tabs + +Use `tab` to group related blocks into labelled panels: + +```json +{ + "type": "tab", + "panels": [ + { + "label": "General", + "blocks": [{ "type": "context", "text": "General settings" }] + } + ] +} +``` + +### Accordion + +```json +{ + "type": "accordion", + "label": "Advanced settings", + "default_open": false, + "blocks": [{ "type": "context", "text": "Settings visible when expanded" }] +} +``` + +## Repeater and media picker elements + +`repeater` and `media_picker` are admin-authoring elements, not ordinary fields in a sandboxed admin-page `form`. + +`repeater` captures an array of objects. Its nested fields are limited to `text_input`, `number_input`, `select`, and `toggle`: + +```typescript +{ + "type": "repeater", + "action_id": "items", + "label": "Questions", + "item_label": "Question", + "fields": [ + { "type": "text_input", "action_id": "question", "label": "Question" }, + { "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true } + ] +} +``` + +`media_picker` opens the media library and stores the selected asset's URL string: + +```typescript +{ + "type": "media_picker", + "action_id": "hero", + "label": "Hero image", + "mime_type_filter": "image/" +} +``` + +## Declarative field widgets + +The admin field editor can render a plugin field widget from Block Kit elements. A schema field refers to `pluginId:widgetName`; a config-declared standard descriptor supplies `name`, `label`, compatible `fieldTypes`, and `elements`. + +The field-widget renderer currently supports only these elements: + +- `text_input` +- `number_input` +- `toggle` +- `select` +- `media_picker` + +It stores an object keyed by each element's `action_id`. Use a `json` field for this composed value; other field types are accepted by the manifest schema but are not covered by an end-to-end save test. Other element types render an unsupported-element message. + +`emdash-plugin.jsonc` accepts this field-widget definition, and the plugin CLI preserves it in the registry manifest and generated descriptor. The artifact transport is tested; the repository's browser E2E test still covers a native React field widget rather than a registry declarative widget. Verify the rendered editor and saved value for the selected elements. + +## Conditional Fields + +Show/hide fields based on other field values. Evaluated client-side, no round-trip. + +```json +{ + "type": "toggle", + "action_id": "auth_enabled", + "label": "Enable Authentication" +} +``` + +```json +{ + "type": "secret_input", + "action_id": "api_key", + "label": "API Key", + "condition": { "field": "auth_enabled", "eq": true } +} +``` + +## Builder Helpers + +`@emdash-cms/blocks` provides TypeScript helpers: + +```typescript +import { blocks, elements } from "@emdash-cms/blocks"; + +const { + header, + form, + section, + stats, + timeseriesChart, + customChart, + banner: bannerBlock, + empty, + accordion, +} = blocks; +const { textInput, toggle, select, button, repeater, mediaPicker } = elements; + +return { + blocks: [ + header("Settings"), + form({ + blockId: "settings", + fields: [ + textInput("site_title", "Site Title", { initialValue: "My Site" }), + toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }), + select("robots", "Default Robots", [ + { label: "Index, Follow", value: "index,follow" }, + { label: "No Index", value: "noindex,follow" }, + ]), + ], + submit: { label: "Save", actionId: "save" }, + }), + // Timeseries chart + timeseriesChart({ + series: [ + { + name: "Page Views", + data: [ + [Date.now() - 3600000, 100], + [Date.now(), 150], + ], + }, + ], + yAxisName: "Views", + gradient: true, + }), + // Pie chart via custom ECharts options + customChart({ + options: { + series: [ + { + type: "pie", + data: [ + { value: 335, name: "Published" }, + { value: 234, name: "Draft" }, + ], + }, + ], + }, + }), + ], +}; +``` + +## Button Confirmations + +```json +{ + "type": "button", + "label": "Delete All", + "action_id": "delete_all", + "style": "danger", + "confirm": { + "title": "Are you sure?", + "text": "This cannot be undone.", + "confirm": "Delete", + "deny": "Cancel" + } +} +``` + +## Saved-entry panels and actions + +`admin.editorPanels` and `admin.editorActions` point to private plugin routes. Panel routes return Block Kit and receive `panel_load`, `block_action`, or `form_submit`. Action routes receive `editor_action` and return only these bounded fields: + +```typescript +{ + toast?: { message: string; type: "success" | "error" | "info" }; + refresh?: true; + navigate?: LinkTarget; + patch?: EditorDraftPatchEffect; +} +``` + +Use only one of `refresh`, `navigate`, or `patch`. Navigation uses the same structured target validator as `link` elements. A danger action declaration must include a confirmation dialog. + +For both surfaces, `routeCtx.ui.entry` contains the host-reloaded collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the manifest declaration. Ordinary panel load contains no draft. An explicit interaction includes selected unsaved fields only when the plugin has `admin.editor-draft:read` and the extension declares bounded `draft.read` access. `admin.editor-draft:patch` separately permits atomic whole-field `set` and `clear` proposals for fields in `draft.patch`; the host previews accepted changes without saving them. + +## Links and admin locale + +Use a structured link target instead of returning an admin URL: + +```json +{ + "type": "link", + "label": "Edit article", + "target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" }, + "appearance": "primary" +} +``` + +Targets can identify saved content, a page declared by the same plugin, the plugin's generated settings page, or an absolute external HTTP, HTTPS, or `mailto:` URL. External links open in a new tab with `noopener noreferrer`. A link has no `action_id`; use a button when the interaction must call the plugin. + +Every page and widget response is validated before rendering. Responses are limited to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string. Root-relative image URLs are accepted. External images require HTTPS and either `network:request` with the hostname in the plugin manifest's `allowedHosts`, or `network:request:unrestricted`. + +The admin route receives host-attested UI context separately from the interaction: + +```typescript +const { locale, direction, surface } = routeCtx.ui ?? { + locale: "en", + direction: "ltr", + surface: "admin-page", +}; +``` + +The UI locale is the administrator's active locale. It is separate from the site's default content locale in `ctx.site.locale`. Runtime page labels can use it to select localized Block Kit text; manifest navigation labels remain static. + +## Toast Responses + +Return a `toast` alongside blocks to show a notification: + +```typescript +return { + blocks: [/* ... */], + toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info" +}; +``` diff --git a/potluck/.agents/skills/creating-plugins/references/comments.md b/potluck/.agents/skills/creating-plugins/references/comments.md new file mode 100644 index 0000000..24c240e --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/comments.md @@ -0,0 +1,13 @@ +# Comment administration + +Comment hooks and stored-comment administration have separate authority. + +All comment hooks require `users:read` because their events contain author and request-derived information. `comments:read` separately exposes stored non-trashed comments through `ctx.comments.get()`, `list()`, and `count()`. + +Comment reads include author name and email, body, pseudonymous IP hash, user agent, and moderation metadata. They do not expose the linked EmDash user-account ID. Treat this as personal-data access. + +`comments:moderate` implies read and adds `ctx.comments.setStatus()`. Supply the status observed by the caller and move only between `approved`, `pending`, and `spam`. A stale status returns `COMMENT_STATUS_CONFLICT`; read again before deciding whether to retry. + +A successful plugin transition identifies `{ source: "plugin", pluginId }`, runs `comment:afterModerate` once, preserves approval notifications, and rejects recursive moderation. The API does not hard-delete comments or replace statuses in bulk. + +Use the runtime test host when a test must prove the personal-data shape, conflict behavior, notifications, hook origin, recursion fencing, or the real comment HTTP/runtime path. diff --git a/potluck/.agents/skills/creating-plugins/references/content.md b/potluck/.agents/skills/creating-plugins/references/content.md new file mode 100644 index 0000000..3da2767 --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/content.md @@ -0,0 +1,41 @@ +# Content, schema, translations, and publication + +Plugin content APIs are capability-gated and shared by native, Cloudflare Worker Loader, and Node/workerd execution. + +## Discovery and reads + +`schema:read` exposes `ctx.schema.listCollections()` and `getCollection()` for batched public collection and field definitions. + +`content:read` exposes `ctx.content.get()`, `list()`, `getTranslations()`, and `getPublicUrl()`. Content results include identity, slug, status, locale, data, timestamps, author ID, translation group, live and draft revision pointers, and row version. Public URL resolution returns only published routable URLs, never previews. + +`content:revisions:read` adds `listRevisions()` and `getRevision()`. Revision snapshots can retain field values removed later but omit revision-author identity. + +## Writes and translations + +`content:write` adds create, update, and delete. Create a translation with: + +```typescript +await ctx.content!.create("posts", data, { locale: "fr", translationOf: sourceId }); +``` + +The source must be an active entry in the same collection. The new row joins its translation group and inherits non-translatable fields, byline credits, and taxonomy assignments. Validation and save hooks run, with re-entrancy fencing for the creating plugin. A group permits one active row per locale. + +Stable failures include `CONFLICT`, `NOT_FOUND`, `VALIDATION_ERROR`, and `SAVE_REJECTED`. + +## Publication policy + +`hooks.content-policy:register` enables `content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` without granting content reads, writes, or publication actions. Return `{ cancel: true, reason }` to reject an action. + +Events identify API, MCP, visual-editor, plugin, scheduler, and system origins. Authenticated human actions include actor identity and source. Scheduled publication runs the publish policy again; rejection unschedules the entry and records the bounded reason for administrators. + +## Publication and restore actions + +`content:publish` adds `getVersioned()`, `publish()`, `unpublish()`, `schedule()`, and `unschedule()`. Read first and pass the opaque `_rev` to every mutation. A successful action returns the next revision and runs the normal policy, synchronization, media-usage, cache-invalidation, and after-hook behavior. + +`content:restore` separately adds `getTrashedVersioned()` and `restore()` without granting ordinary content reads. It does not grant permanent deletion. + +Plugin actions report `{ source: "plugin", pluginId }`. Re-entering the same action for the same canonical entry from that plugin is rejected. + +## Runtime tests + +Use runtime fixtures for initial entries, translations, bylines, and taxonomy assignments. Use runtime actions for publication paths and inspectors for persisted state. Test stale revisions, policy rejection, duplicate locales, re-entrancy, restart, and cache invalidation when those behaviors matter. diff --git a/potluck/.agents/skills/creating-plugins/references/hooks.md b/potluck/.agents/skills/creating-plugins/references/hooks.md new file mode 100644 index 0000000..2980a7b --- /dev/null +++ b/potluck/.agents/skills/creating-plugins/references/hooks.md @@ -0,0 +1,585 @@ +# Hooks + +Sandboxed plugins declare hooks in the typed default export from `src/plugin.ts`. The `SandboxedPlugin` type infers each event and return type from the hook name. + +## Signature + +```typescript +async (event: EventType, ctx: PluginContext) => ReturnType; +``` + +## Configuration + +Simple handler or full config: + +```typescript +import type { SandboxedPlugin } from "emdash/plugin"; + +const plugin: SandboxedPlugin = { + hooks: { + "content:afterSave": { + priority: 100, + timeout: 5000, + handler: async (event, ctx) => { + ctx.log.info("Saved", { id: event.content.id }); + }, + }, + }, +}; + +export default plugin; +``` + +The plugin CLI manifest retains `priority`, `timeout`, `dependencies`, `errorPolicy`, and `exclusive`. Registry-installed and config-managed sandbox hooks enter the shared host pipeline, so these settings apply consistently with trusted plugins. + +Put capabilities in `emdash-plugin.jsonc`, not in `src/plugin.ts`. The host pipeline skips sandboxed and trusted hooks whose required capability is absent. + +## Isolated sandbox support + +The current isolated runtime dispatches these hooks to Cloudflare and Node/workerd sandbox instances through the shared host pipeline: + +- `plugin:install`, `plugin:activate`, `plugin:deactivate`, and `plugin:uninstall` +- `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, and `content:afterDelete` +- `content:beforePublish`, `content:beforeSchedule`, `content:beforeUnpublish`, `content:afterPublish`, `content:afterUnpublish`, `content:afterRestore`, `content:afterSchedule`, and `content:afterUnschedule` +- `media:beforeUpload` and `media:afterUpload` +- `email:beforeSend`, `email:deliver`, and `email:afterSend` +- `comment:beforeCreate`, `comment:moderate`, `comment:afterCreate`, and `comment:afterModerate` +- `cron` +- `page:metadata` + +`page:fragments` is the only declared hook excluded from sandbox registration. The plugin CLI accepts it and emits a trusted-only warning; the sandbox host proxy drops it before the hook pipeline is built. + +## Lifecycle Hooks + +### `plugin:install` + +Runs once on first install. Use to seed defaults. + +```typescript +"plugin:install": async (_event, ctx) => { + await ctx.settings.set("enabled", true); + await ctx.storage.items!.put("default", { name: "Default" }); +} +``` + +Event: `{}` +Returns: `void` + +### `plugin:activate` + +Runs when plugin is enabled (after install or re-enable). + +```typescript +"plugin:activate": async (_event, ctx) => { + ctx.log.info("Activated"); +} +``` + +Event: `{}` +Returns: `void` + +### `plugin:deactivate` + +Runs when plugin is disabled (not removed). + +```typescript +"plugin:deactivate": async (_event, ctx) => { + ctx.log.info("Deactivated"); +} +``` + +Event: `{}` +Returns: `void` + +### `plugin:uninstall` + +Runs when plugin is removed. Only delete data if `event.deleteData` is true. + +```typescript +"plugin:uninstall": async (event, ctx) => { + if (event.deleteData) { + const result = await ctx.storage.items!.query({ limit: 1000 }); + await ctx.storage.items!.deleteMany(result.items.map(i => i.id)); + } +} +``` + +Event: `{ deleteData: boolean }` +Returns: `void` + +## Content Hooks + +### `content:beforeSave` + +Runs before save. Return modified content, or void to keep it unchanged. To reject from the sandbox, return `{ __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason } }`. The reason must be 1–500 characters of plain text. EmDash identifies the plugin and shows the reason as text; invalid or unknown error results fail with a generic hook error. From the host process, throw `ContentSaveRejectedError` (exported from `emdash`) instead. Any other exception from either execution mode fails the save with a generic message. + +```typescript +"content:beforeSave": async (event, ctx) => { + const { content } = event; + if (!content.title) { + return { + __emdashSandboxHookResult: true, + version: 1, + error: { code: "SAVE_REJECTED", reason: "Add a title before saving." }, + }; + } + + // Transform + if (content.slug) { + content.slug = content.slug.toLowerCase().replace(/\s+/g, "-"); + } + + return content; +} +``` + +Event: `{ content: Record, collection: string, isNew: boolean, id?: string, actor?: { id: string, role: number } }`. Authenticated REST, visual editing, and MCP saves include a read-only actor snapshot; internal writes may omit it. On updates, `id` identifies the existing item. The actor snapshot does not identify the request origin, so a hook cannot distinguish REST, visual editing, MCP, or another authenticated path from this field alone. +Returns: `Record | SandboxHookErrorEnvelope | void` + +### `content:afterSave` + +Runs after successful save. Side effects only — logging, notifications, syncing. + +```typescript +"content:afterSave": async (event, ctx) => { + const { content, collection, isNew } = event; + ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`); +} +``` + +Event: `{ content: Record, collection: string, isNew: boolean, actor?: { id: string, role: number } }`. Authenticated saves include a read-only actor snapshot; internal writes may omit it. +Returns: `void` + +### `content:beforeDelete` + +Runs before delete. Return `false` to cancel, `true` or void to allow. + +```typescript +"content:beforeDelete": async (event, ctx) => { + if (event.collection === "pages" && event.id === "home") { + ctx.log.warn("Cannot delete home page"); + return false; + } + return true; +} +``` + +Event: `{ id: string, collection: string }` +Returns: `boolean | void` + +### `content:afterDelete` + +Runs after successful delete. + +```typescript +"content:afterDelete": async (event, ctx) => { + ctx.log.info(`Deleted ${event.collection}/${event.id}`); + await ctx.storage.cache!.delete(`${event.collection}:${event.id}`); +} +``` + +Event: `{ id: string, collection: string }` +Returns: `void` + +### Publication policy hooks + +`content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` require `hooks.content-policy:register`. This authority is independent of `content:read`, `content:write`, and publication actions. + +Publish and schedule events expose the effective draft in `content.data` and the staged slug in `content.slug`. Unpublish events expose the currently live content that the action would remove. + +Return `void` to allow the action or `{ cancel: true, reason }` to reject it. The reason must contain 1–500 plain-text characters. Invalid decisions and unexpected abort-policy errors stop the action with a generic failure. Explicit cancellations return `PUBLISH_REJECTED`, `SCHEDULE_REJECTED`, or `UNPUBLISH_REJECTED`. + +```typescript +"content:beforePublish": async (event) => { + const data = event.content.data; + const approvalStatus = + typeof data === "object" && data !== null && "approval_status" in data + ? data.approval_status + : undefined; + if (approvalStatus !== "approved") { + return { cancel: true, reason: "Approve this entry before publishing." }; + } +}, +``` + +Events contain `{ content, collection, origin, actor? }`; `content:beforeSchedule` also contains `scheduledAt`. Human origins are `api`, `mcp`, or `visual-editor` and include the same value in `actor.source`. The visual-editor origin requires the signed short-lived token from an authenticated toolbar render. Other origins are `plugin` (with `pluginId`), `scheduler`, and `system`. + +Scheduled content runs `content:beforePublish` again when it becomes due. An explicit scheduler rejection unschedules the entry and lists its reason on the dashboard until the entry is rescheduled, published, deleted, or the record is dismissed. There is no `content:beforeUnschedule`, so an administrator can always cancel a future publication. + +### `content:afterPublish` + +Runs after content is published (promoted from draft to live). Side effects only. + +```typescript +"content:afterPublish": async (event, ctx) => { + ctx.log.info(`Published ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnpublish` + +Runs after content is unpublished (reverted to draft). Side effects only. + +```typescript +"content:afterUnpublish": async (event, ctx) => { + ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterRestore` + +Runs after trashed content is restored. Side effects only. + +```typescript +"content:afterRestore": async (event, ctx) => { + ctx.log.info(`Restored ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterSchedule` + +Runs after content is scheduled for future publishing. Side effects only. + +```typescript +"content:afterSchedule": async (event, ctx) => { + ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +### `content:afterUnschedule` + +Runs after scheduled content is unscheduled. Side effects only. + +```typescript +"content:afterUnschedule": async (event, ctx) => { + ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`); +} +``` + +Event: `{ content: Record, collection: string }` +Returns: `void` + +## Media Hooks + +### `media:beforeUpload` + +Runs before upload. Return modified file info, void to keep, or throw to cancel. + +```typescript +"media:beforeUpload": async (event, ctx) => { + const { file } = event; + + if (!file.type.startsWith("image/")) { + throw new Error("Only images allowed"); + } + + if (file.size > 10 * 1024 * 1024) { + throw new Error("Max 10MB"); + } + + return { ...file, name: `${Date.now()}-${file.name}` }; +} +``` + +Event: `{ file: { name: string, type: string, size: number } }` +Returns: `{ name: string, type: string, size: number } | void` + +### `media:afterUpload` + +Runs after successful upload. + +```typescript +"media:afterUpload": async (event, ctx) => { + ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id }); +} +``` + +Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }` +Returns: `void` + +## Email Hooks + +Email hooks require specific capabilities. Without the required capability, hooks are silently skipped. + +### `email:beforeSend` + +**Requires:** `hooks.email-events:register` capability. + +Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one. + +```typescript +// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"] +"email:beforeSend": async (event) => { + return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" }; +}, +``` + +Event: `{ message: EmailMessage, source: string }` +Returns: `EmailMessage | false` + +### `email:deliver` + +**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active. + +Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email. + +```typescript +// emdash-plugin.jsonc declares hooks.email-transport:register and network:request, +// with api.resend.com in allowedHosts. +"email:deliver": { + exclusive: true, + handler: async ({ message }, ctx) => { + const apiKey = await ctx.settings.get("apiKey"); + await ctx.http!.fetch("https://api.resend.com/emails", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + to: message.to, + cc: message.cc, + reply_to: message.replyTo, + subject: message.subject, + text: message.text, + }), + }); + }, +}, +``` + +Event: `{ message: EmailMessage, source: string }` +Returns: `void` + +### `email:afterSend` + +**Requires:** `hooks.email-events:register` capability. + +Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate. + +```typescript +// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"] +"email:afterSend": async (event, ctx) => { + ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source }); +}, +``` + +Event: `{ message: EmailMessage, source: string }` +Returns: `void` + +## Comment hooks + +Comment hooks run for sandboxed and trusted plugins through the shared pipeline. All four require `users:read`. Their events include author email and request-derived information, so the pipeline skips the hook when the capability is absent. + +### `comment:beforeCreate` + +Runs before storage. Return a modified event to enrich the comment or its moderator-only metadata, return `false` to reject the comment, or return nothing to keep it unchanged. + +```typescript +"comment:beforeCreate": async (event) => { + if (event.comment.body.includes("blocked phrase")) return false; + return { + ...event, + metadata: { ...event.metadata, reviewedBy: "rules-v1" }, + }; +}, +``` + +Event: + +```typescript +{ + comment: { + collection: string; + contentId: string; + parentId: string | null; + authorName: string; + authorEmail: string; + authorUserId: string | null; + body: string; + ipHash: string | null; + userAgent: string | null; + } + metadata: Record; +} +``` + +Returns: the event, `false`, or `void`. + +### `comment:moderate` + +The exclusive moderation provider decides the initial status after the enrichment pipeline. + +```typescript +"comment:moderate": { + exclusive: true, + handler: async (event) => ({ + status: event.priorApprovedCount > 0 ? "approved" : "pending", + reason: "First-time authors require review", + }), +}, +``` + +The event contains `comment`, `metadata`, collection comment settings, and `priorApprovedCount`. Return `{ status: "approved" | "pending" | "spam", reason?: string }`. + +### `comment:afterCreate` + +Runs after the comment is stored. The event contains the stored comment, moderation metadata, the target content summary, and the content author when available. Use it for notifications and other side effects. Returns `void`. + +### `comment:afterModerate` + +Runs after an administrator or a plugin changes a comment's status. The event contains the stored comment, `previousStatus`, `newStatus`, the moderator's `{ id, name }`, and `origin`. Administrator changes use `{ source: "admin", userId }`; `ctx.comments.setStatus()` uses `{ source: "plugin", pluginId }`. A transition runs the hook once. Returns `void`. + +## Cron Hook + +### `cron` + +Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`. + +```typescript +hooks: { + "plugin:activate": async (_event, ctx) => { + await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" }); + }, + cron: async (event, ctx) => { + if (event.name === "daily-cleanup") { + // ... cleanup logic + } + }, +}, +``` + +Event: `{ name: string, data?: Record }` +Returns: `void` + +## Public Page Hooks + +Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with ``, ``, and `` components. + +### `page:metadata` + +Contributes typed metadata to `` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes. + +Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook. + +```typescript +"page:metadata": async (event, ctx) => { + if (event.page.kind !== "content") return null; + + return [ + { kind: "meta", name: "author", content: "My Blog" }, + { + kind: "jsonld", + id: `schema:${event.page.content?.collection}:${event.page.content?.id}`, + graph: { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: event.page.pageTitle ?? event.page.title, + description: event.page.description, + }, + }, + ]; +} +``` + +Event: `{ page: PublicPageContext }` +Returns: `PageMetadataContribution | PageMetadataContribution[] | null` + +Contribution types: + +- `{ kind: "meta", name: string, content: string, key?: string }` — `` +- `{ kind: "property", property: string, content: string, key?: string }` — `` (OpenGraph) +- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }` — `` tag (HTTP/HTTPS URLs only) +- `{ kind: "jsonld", id?: string, graph: object | object[] }` — ` + + + + + +
    + +
    + + + + + + + + + + + + + diff --git a/potluck/src/live.config.ts b/potluck/src/live.config.ts new file mode 100644 index 0000000..c8c819d --- /dev/null +++ b/potluck/src/live.config.ts @@ -0,0 +1,13 @@ +/** + * EmDash Live Content Collections + * + * Defines the _emdash collection that handles all content types from the database. + * Query specific types using getEmDashCollection() and getEmDashEntry(). + */ + +import { defineLiveCollection } from "astro:content"; +import { emdashLoader } from "emdash/runtime"; + +export const collections = { + _emdash: defineLiveCollection({ loader: emdashLoader() }), +}; diff --git a/potluck/src/pages/404.astro b/potluck/src/pages/404.astro new file mode 100644 index 0000000..0078a14 --- /dev/null +++ b/potluck/src/pages/404.astro @@ -0,0 +1,33 @@ +--- +import Base from "../layouts/Base.astro"; +--- + + +
    +

    404

    +

    The page you're looking for doesn't exist.

    + Go back home +
    + + + diff --git a/potluck/src/pages/category/[slug].astro b/potluck/src/pages/category/[slug].astro new file mode 100644 index 0000000..15ab4d1 --- /dev/null +++ b/potluck/src/pages/category/[slug].astro @@ -0,0 +1,146 @@ +--- +import { + getTaxonomyTermsWithCacheHint, + getEmDashCollection, + getTermsForEntries, + decodeSlug, +} from "emdash"; +import Base from "../../layouts/Base.astro"; +import PostCard from "../../components/PostCard.astro"; +import { getReadingTime } from "../../utils/reading-time"; + +const slug = decodeSlug(Astro.params.slug); +const termsResult = await getTaxonomyTermsWithCacheHint("category", { + includeCounts: false, +}); +const term = slug ? termsResult.data.find((item) => item.slug === slug) : null; + +if (!term) { + return Astro.redirect("/404"); +} + +const cursor = Astro.url.searchParams.get("cursor") || undefined; +const { + entries: posts, + cacheHint, + nextCursor, +} = await getEmDashCollection("posts", { + where: { category: term.slug }, + orderBy: { published_at: "desc" }, + limit: 20, + cursor, +}); + +if (Astro.cache?.enabled) { + Astro.cache.set(termsResult.cacheHint); + Astro.cache.set(cacheHint); +} + +// Single batched query for tags on every post in this category, rather +// than calling getEntryTerms() per post (which would be one round-trip +// per post). +const tagsByEntry = await getTermsForEntries( + "posts", + posts.map((p) => p.data.id), + "tag", +); +const filteredPosts = posts.map((post) => ({ + post, + tags: tagsByEntry.get(post.data.id) ?? [], +})); +--- + + +
    +
    + Category +

    {term.label}

    +
    + + { + filteredPosts.length === 0 ? ( +

    No posts in this category yet.

    + ) : ( +
    + {filteredPosts.map(({ post, tags }) => ( + ({ slug: t.slug, label: t.label }))} + edit={post.edit} + /> + ))} +
    + ) + } + +
    + + + diff --git a/potluck/src/pages/index.astro b/potluck/src/pages/index.astro new file mode 100644 index 0000000..f708e54 --- /dev/null +++ b/potluck/src/pages/index.astro @@ -0,0 +1,466 @@ +--- +import { + getEmDashCollection, + getTermsForEntries, + getSiteSettings, +} from "emdash"; +import { Image } from "emdash/ui"; +import Base from "../layouts/Base.astro"; +import PostCard from "../components/PostCard.astro"; +import { getReadingTime } from "../utils/reading-time"; +import { resolveBlogSiteIdentity } from "../utils/site-identity"; + +// Limit to what we render (1 featured + 6 grid). The DB does the slicing +// instead of fetching every post and discarding the tail in JS. +const POSTS_PER_PAGE = 7; + +const [{ entries: posts, cacheHint }, settings] = await Promise.all([ + getEmDashCollection("posts", { + orderBy: { published_at: "desc" }, + limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need + }), + getSiteSettings(), +]); +const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings); + +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); + +// Trim the lookahead post used to detect overflow +const visiblePosts = posts.slice(0, POSTS_PER_PAGE); +const hasMorePosts = posts.length > POSTS_PER_PAGE; + +// Find the first post with a featured image for the hero +const featuredPost = visiblePosts.find((p) => p.data.featured_image); +const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1; + +// Get remaining posts (exclude featured if found, limit to 6 for grid) +const gridPosts = visiblePosts + .filter((_, i) => i !== featuredIndex) + .slice(0, 6); + +// Single batched query for tags across the featured post + grid posts. +// Avoids the N+1 pattern of calling getEntryTerms() per entry. +// Bylines are already hydrated on entry.data.bylines by getEmDashCollection. +const tagEntryIds = [ + ...(featuredPost ? [featuredPost.data.id] : []), + ...gridPosts.map((p) => p.data.id), +]; +const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag"); + +const featuredTags = featuredPost + ? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({ + slug: t.slug, + label: t.label, + })) + : []; +const featuredBylines = featuredPost?.data.bylines ?? []; + +const gridPostsWithTags = gridPosts.map((post) => ({ + post, + tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({ + slug: t.slug, + label: t.label, + })), + bylines: post.data.bylines ?? [], +})); + +// Format date helper +function formatDate(date: Date | null | undefined) { + if (!date) return null; + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} +--- + + + { + posts.length === 0 ? ( +
    +

    No posts yet

    +

    Create your first post in the admin panel.

    + + Create a post + +
    + ) : ( +
    + {/* Featured Post - Side by side */} + {featuredPost && ( + + )} + + {/* Latest Posts */} + {gridPostsWithTags.length > 0 && ( +
    +
    +

    Latest

    + {hasMorePosts && ( + + View all + + )} +
    +
    + {gridPostsWithTags.map(({ post, tags, bylines }) => ( + + ))} +
    +
    + )} +
    + ) + } + + + diff --git a/potluck/src/pages/pages/[slug].astro b/potluck/src/pages/pages/[slug].astro new file mode 100644 index 0000000..be5473d --- /dev/null +++ b/potluck/src/pages/pages/[slug].astro @@ -0,0 +1,124 @@ +--- +import { + getEmDashEntry, + getSeoMeta, + decodeSlug, + getSiteSettings, +} from "emdash"; +import { PortableText } from "emdash/ui"; +import Base from "../../layouts/Base.astro"; +import { resolveBlogSiteIdentity } from "../../utils/site-identity"; + +const slug = decodeSlug(Astro.params.slug); + +if (!slug) { + return Astro.redirect("/404"); +} + +const { entry: page, cacheHint } = await getEmDashEntry("pages", slug); + +if (!page) { + return Astro.redirect("/404"); +} + +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); +const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings()); +const seo = getSeoMeta(page, { + siteTitle, + siteUrl: Astro.url.origin, + path: Astro.url.pathname.replace(/\/$/, ""), +}); +--- + + +
    + + +
    + +
    +
    + + + diff --git a/potluck/src/pages/posts/[slug].astro b/potluck/src/pages/posts/[slug].astro new file mode 100644 index 0000000..e130a0d --- /dev/null +++ b/potluck/src/pages/posts/[slug].astro @@ -0,0 +1,968 @@ +--- +import { + getEmDashEntry, + getEmDashCollection, + getTermsForEntries, + getSeoMeta, + decodeSlug, + getSiteSettings, +} from "emdash"; +import { Image, PortableText, WidgetArea } from "emdash/ui"; +import { Comments, CommentForm } from "emdash/ui/comments"; +import Base from "../../layouts/Base.astro"; +import PostCard from "../../components/PostCard.astro"; +import { getReadingTime } from "../../utils/reading-time"; +import { resolveBlogSiteIdentity } from "../../utils/site-identity"; + +const slug = decodeSlug(Astro.params.slug); + +if (!slug) { + return Astro.redirect("/404"); +} + +const { entry: post, cacheHint } = await getEmDashEntry("posts", slug); + +if (!post) { + return Astro.redirect("/404"); +} + +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); + +// Get featured image URL for OG fallback +// The image may have src (external) or meta.storageKey (local) +function getImageUrl(img: unknown): string | undefined { + if (!img || typeof img !== "object") return undefined; + const image = img as Record; + // Check for direct src + if (typeof image.src === "string" && image.src) { + return image.src.startsWith("http") + ? image.src + : `${Astro.url.origin}${image.src}`; + } + // Build from storageKey for local images + const meta = image.meta as Record | undefined; + const storageKey = + (typeof meta?.storageKey === "string" ? meta.storageKey : undefined) || + (typeof image.id === "string" ? image.id : undefined); + if (storageKey) { + return `${Astro.url.origin}/_emdash/api/media/file/${storageKey}`; + } + return undefined; +} +const featuredImageUrl = getImageUrl(post.data.featured_image); +const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings()); + +// Generate SEO meta from content +const seo = getSeoMeta(post, { + siteTitle, + siteUrl: Astro.url.origin, + path: Astro.url.pathname.replace(/\/$/, ""), + defaultOgImage: featuredImageUrl, +}); + +// Bylines are already hydrated by getEmDashEntry +const bylines = post.data.bylines ?? []; + +// Get reading time +const readingTime = getReadingTime(post.data.content); + +// This post's tags are already hydrated onto the entry by getEmDashEntry — +// no extra query needed. +const tags = post.data.terms?.tag ?? []; + +// Fetch a few extra recent posts in case the current post is among them. +const { entries: recentPosts, cacheHint: recentPostsCacheHint } = await getEmDashCollection( + "posts", + { + orderBy: { published_at: "desc" }, + limit: 4, + }, +); +if (Astro.cache?.enabled) Astro.cache.set(recentPostsCacheHint); +const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3); + +// Single batched query for related-posts tags, rather than one +// getEntryTerms() call per related post. +const otherTagsByEntry = await getTermsForEntries( + "posts", + otherPosts.map((p) => p.data.id), + "tag", +); +const otherPostsWithTags = otherPosts.map((p) => ({ + post: p, + tags: otherTagsByEntry.get(p.data.id) ?? [], + bylines: p.data.bylines ?? [], +})); + +const publishDate = + post.data.publishedAt?.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }) ?? null; +--- + + +
    + {/* Hero: Full-width featured image */} + { + post.data.featured_image && ( +
    + +
    + ) + } + + {/* Three-column layout */} +
    + {/* Left gutter: Meta information */} + + + {/* Main content */} +
    +
    + +

    {post.data.title}

    + { + post.data.excerpt && ( +

    + {post.data.excerpt} +

    + ) + } +
    + +
    + +
    + +
    + + +
    +
    + + {/* Right gutter: TOC + Sidebar widgets */} + +
    +
    + + { + otherPostsWithTags.length > 0 && ( + + ) + } + + + + + diff --git a/potluck/src/pages/posts/index.astro b/potluck/src/pages/posts/index.astro new file mode 100644 index 0000000..36c1bc5 --- /dev/null +++ b/potluck/src/pages/posts/index.astro @@ -0,0 +1,289 @@ +--- +import { getEmDashCollection, getTermsForEntries } from "emdash"; +import Base from "../../layouts/Base.astro"; +import { getReadingTime } from "../../utils/reading-time"; + +// Sort in the database rather than in JS — lets the DB use its index on +// published_at and avoids a full-table scan on the client. +const cursor = Astro.url.searchParams.get("cursor") || undefined; +const { + entries: posts, + cacheHint, + nextCursor, +} = await getEmDashCollection("posts", { + orderBy: { published_at: "desc" }, + limit: 20, + cursor, +}); + +if (Astro.cache?.enabled) Astro.cache.set(cacheHint); + +// Single batched query for tags across all posts, instead of one +// getEntryTerms() call per post (which would be N round-trips). +// Bylines are already hydrated on entry.data.bylines. +const tagsByEntry = await getTermsForEntries( + "posts", + posts.map((p) => p.data.id), + "tag", +); + +const postsWithTags = posts.map((post) => ({ + post, + tags: tagsByEntry.get(post.data.id) ?? [], + bylines: post.data.bylines ?? [], +})); + +const formatDate = (date: Date) => + date.toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +--- + + + + + + diff --git a/potluck/src/pages/rss.xml.ts b/potluck/src/pages/rss.xml.ts new file mode 100644 index 0000000..0dfd56b --- /dev/null +++ b/potluck/src/pages/rss.xml.ts @@ -0,0 +1,70 @@ +import type { APIRoute } from "astro"; +import { getEmDashCollection, getSiteSettings } from "emdash"; + +import { resolveBlogSiteIdentity } from "../utils/site-identity"; + +export const GET: APIRoute = async ({ site, url }) => { + const siteUrl = site ?? new URL(url.origin); + const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings()); + + const { entries: posts } = await getEmDashCollection("posts", { + orderBy: { published_at: "desc" }, + limit: 20, + }); + + const items = posts + .map((post) => { + if (!post.data.publishedAt) return null; + const pubDate = post.data.publishedAt.toUTCString(); + + const postUrl = new URL(`/posts/${post.id}`, siteUrl).href; + const title = escapeXml(post.data.title || "Untitled"); + const description = escapeXml(post.data.excerpt || ""); + + return ` + ${title} + ${postUrl} + ${postUrl} + ${pubDate} + ${description} + `; + }) + .filter(Boolean) + .join("\n"); + + const rss = ` + + + ${escapeXml(siteTitle)} + ${escapeXml(siteTagline)} + ${siteUrl.href} + + en-us + ${new Date().toUTCString()} +${items} + +`; + + return new Response(rss, { + headers: { + "Content-Type": "application/rss+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); +}; + +const XML_ESCAPE_PATTERNS = [ + [/&/g, "&"], + [//g, ">"], + [/"/g, """], + [/'/g, "'"], +] as const; + +function escapeXml(str: string): string { + let result = str; + for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) { + result = result.replace(pattern, replacement); + } + return result; +} diff --git a/potluck/src/pages/search.astro b/potluck/src/pages/search.astro new file mode 100644 index 0000000..aa7c7ac --- /dev/null +++ b/potluck/src/pages/search.astro @@ -0,0 +1,196 @@ +--- +export const prerender = false; + +import { search } from "emdash"; +import Base from "../layouts/Base.astro"; + +const query = Astro.url.searchParams.get("q")?.trim() || ""; + +// Use the FTS-backed search() API instead of loading every post and +// filtering in JS. FTS scales as the post count grows, returns ranked +// results, and handles tokenization/stemming. Templates that grep all +// post bodies in JS quickly become unusable past a few hundred posts. +const { items: results } = query + ? await search(query, { collections: ["posts"], limit: 30 }) + : { items: [] }; +--- + + +
    +

    Search

    + +
    + + + +
    + + { + query && ( +

    + {results.length === 0 + ? `No results for "${query}"` + : `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`} +

    + ) + } + + { + results.length > 0 && ( +
      + {results.map((result) => ( +
    1. + +

      {result.title ?? "Untitled"}

      + {result.snippet && ( +

      + )} + +

    2. + ))} +
    + ) + } + + {!query &&

    Enter a search term to find posts.

    } +
    + + + diff --git a/potluck/src/pages/tag/[slug].astro b/potluck/src/pages/tag/[slug].astro new file mode 100644 index 0000000..3d28ceb --- /dev/null +++ b/potluck/src/pages/tag/[slug].astro @@ -0,0 +1,148 @@ +--- +import { + getTaxonomyTermsWithCacheHint, + getEmDashCollection, + getTermsForEntries, + decodeSlug, +} from "emdash"; +import Base from "../../layouts/Base.astro"; +import PostCard from "../../components/PostCard.astro"; +import { getReadingTime } from "../../utils/reading-time"; + +const slug = decodeSlug(Astro.params.slug); +const termsResult = await getTaxonomyTermsWithCacheHint("tag", { + includeCounts: false, +}); +const term = slug ? termsResult.data.find((item) => item.slug === slug) : null; + +if (!term) { + return Astro.redirect("/404"); +} + +const cursor = Astro.url.searchParams.get("cursor") || undefined; +const { + entries: posts, + cacheHint, + nextCursor, +} = await getEmDashCollection("posts", { + where: { tag: term.slug }, + orderBy: { published_at: "desc" }, + limit: 20, + cursor, +}); + +if (Astro.cache?.enabled) { + Astro.cache.set(termsResult.cacheHint); + Astro.cache.set(cacheHint); +} + +// Single batched query for tags on every post tagged with this term, +// rather than calling getEntryTerms() per post. +const tagsByEntry = await getTermsForEntries( + "posts", + posts.map((p) => p.data.id), + "tag", +); +const filteredPosts = posts.map((post) => ({ + post, + tags: tagsByEntry.get(post.data.id) ?? [], +})); +--- + + +
    +
    + Tag +

    {term.label}

    +
    + + { + filteredPosts.length === 0 ? ( +

    No posts with this tag yet.

    + ) : ( +
    + {filteredPosts.map(({ post, tags }) => ( + ({ slug: t.slug, label: t.label }))} + edit={post.edit} + /> + ))} +
    + ) + } + +
    + + + diff --git a/potluck/src/styles/theme.css b/potluck/src/styles/theme.css new file mode 100644 index 0000000..d5ad867 --- /dev/null +++ b/potluck/src/styles/theme.css @@ -0,0 +1,33 @@ +/* + theme.css -- your theme overrides. This is the file to edit to + retheme the site. + + Every design token lives in src/styles/tokens.css (colors, type + scale, spacing, layout, shadows) with its default value. Override any + of them here: declarations in this file are unlayered, so they always + beat the @layer base defaults in tokens.css. + + Colors are defined with light-dark(). Overriding with a plain color + changes light and dark mode at once; use light-dark(, ) + to set different values per mode. + + Webfonts are loaded in astro.config.mjs (--font-body, --font-mono). + Swap the loaded font there; use overrides here for system fonts or a + separate heading face. + + Example: + + :root { + --color-brand: light-dark(#0f766e, #2dd4bf); + --font-heading: "Iowan Old Style", Georgia, serif; + --radius: 8px; + } +*/ + +:root { +} + +/* Keep the signed-in Admin link separated from the primary navigation. */ +.nav-admin { + margin-inline-start: var(--spacing-5); +} diff --git a/potluck/src/styles/tokens.css b/potluck/src/styles/tokens.css new file mode 100644 index 0000000..baf58e8 --- /dev/null +++ b/potluck/src/styles/tokens.css @@ -0,0 +1,146 @@ +/* + tokens.css -- the design tokens for this template. These are the + defaults; to customize the site, override any variable in + src/styles/theme.css rather than editing this file. Declarations in + theme.css are unlayered, so they always beat these @layer base + defaults -- no specificity tricks needed. + + Colors use light-dark(): the first value applies in light mode, the + second in dark mode. Overriding a color with a plain value changes + both modes at once; use light-dark(, ) in an override to + keep them distinct. + + --font-body is defined by the font pipeline in astro.config.mjs, not + here. To swap the loaded webfont, edit the fonts entry there. To use a + system font, or a different face for headings only, override + --font-body / --font-heading in theme.css. +*/ + +@layer base { + :root { + color-scheme: light dark; + + /* Colors */ + --color-bg: light-dark(#ffffff, #0d0d0d); + --color-bg-subtle: light-dark(#fafafa, #141414); + --color-text: light-dark(#1a1a1a, #ededed); + --color-text-secondary: light-dark(#525252, #a0a0a0); + --color-muted: light-dark(#8b8b8b, #6b6b6b); + --color-border: light-dark(#e5e5e5, #2a2a2a); + --color-border-subtle: light-dark(#f0f0f0, #1f1f1f); + --color-surface: light-dark(#f7f7f7, #181818); + --color-brand: light-dark(#0066cc, #4d9fff); + --color-brand-hover: light-dark(#0052a3, #6eb0ff); + --color-on-brand: white; + --color-brand-ring: color-mix(in srgb, var(--color-brand) 25%, transparent); + + /* EmDash search theming */ + --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); + + /* Typography */ + --font-heading: var(--font-body); + --font-weight-heading: 600; /* h2-h6, card and list titles */ + --font-weight-display: 700; /* h1 and large page titles */ + + /* Type scale */ + --font-size-xs: 0.8125rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + --font-size-3xl: 2rem; + --font-size-4xl: 2.5rem; + --font-size-5xl: 3.5rem; + + /* Line heights */ + --leading-tight: 1.15; + --leading-snug: 1.3; + --leading-normal: 1.5; + --leading-relaxed: 1.7; + + /* Letter spacing */ + --tracking-tight: -0.03em; /* h1 and large titles */ + --tracking-snug: -0.02em; /* h2-h6, site/card titles */ + --tracking-wide: 0.06em; /* meta labels, TOC/widget titles */ + --tracking-wider: 0.08em; /* footer headings, section labels */ + + /* Spacing */ + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + --spacing-10: 2.5rem; + --spacing-12: 3rem; + --spacing-16: 4rem; + --spacing-20: 5rem; + --spacing-24: 6rem; + + /* Legacy spacing aliases */ + --spacing-xs: var(--spacing-1); + --spacing-sm: var(--spacing-2); + --spacing-md: var(--spacing-4); + --spacing-lg: var(--spacing-6); + --spacing-xl: var(--spacing-8); + --spacing-2xl: var(--spacing-12); + --spacing-3xl: var(--spacing-16); + + /* Layout */ + --content-width: 680px; /* article/page body column */ + --wide-width: 1200px; /* max container (home, archives) */ + --max-width: var(--content-width); + --gutter-width: 200px; /* right sidebar column (TOC) on article pages */ + --meta-col-width: 180px; /* left meta column on article pages */ + --nav-height: 64px; /* sticky header height */ + --search-input-width: 180px; /* nav search box width */ + + /* Borders & radius */ + --radius: 4px; + --radius-lg: 8px; + + /* Transitions */ + --transition-fast: 120ms ease; + --transition-base: 180ms ease; + + /* Avatars */ + --avatar-size-xs: 18px; /* card byline avatars */ + --avatar-size-sm: 20px; /* post list byline avatars */ + --avatar-size-md: 24px; /* featured post byline avatars */ + --avatar-size-lg: 32px; /* single post byline avatars */ + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12); + + /* Misc */ + --tag-padding-y: 2px; /* vertical padding on tag pills */ + } + + /* + * Light-mode values for browsers without light-dark() (Safari < 17.5, + * Chrome < 123). Must mirror the light values above. + */ + @supports not (color: light-dark(#000, #fff)) { + :root { + color-scheme: light; + --color-bg: #ffffff; + --color-bg-subtle: #fafafa; + --color-text: #1a1a1a; + --color-text-secondary: #525252; + --color-muted: #8b8b8b; + --color-border: #e5e5e5; + --color-border-subtle: #f0f0f0; + --color-surface: #f7f7f7; + --color-brand: #0066cc; + --color-brand-hover: #0052a3; + } + } +} diff --git a/potluck/src/utils/reading-time.ts b/potluck/src/utils/reading-time.ts new file mode 100644 index 0000000..c17794e --- /dev/null +++ b/potluck/src/utils/reading-time.ts @@ -0,0 +1,66 @@ +import type { PortableTextBlock } from "emdash"; + +const WORDS_PER_MINUTE = 200; +const CJK_CHARACTERS_PER_MINUTE = 500; +const WHITESPACE_REGEX = /\s+/; +const CJK_CHARACTER_REGEX = + /\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu; + +type PortableTextSpan = { + _type: string; + text?: string; +}; + +type PortableTextTextBlock = PortableTextBlock & { + _type: "block"; + children: PortableTextSpan[]; +}; + +function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock { + return block._type === "block" && Array.isArray(block.children); +} + +function countWords(text: string): number { + return text.split(WHITESPACE_REGEX).filter(Boolean).length; +} + +function countCjkCharacters(text: string): number { + return text.match(CJK_CHARACTER_REGEX)?.length ?? 0; +} + +/** + * Extract plain text from Portable Text blocks + */ +export function extractText(blocks: PortableTextBlock[] | undefined): string { + if (!blocks || !Array.isArray(blocks)) return ""; + + return blocks + .filter(isTextBlock) + .map((block) => + block.children + .filter((child) => child._type === "span" && typeof child.text === "string") + .map((span) => span.text) + .join(""), + ) + .join(" "); +} + +/** + * Calculate reading time in minutes from Portable Text content + */ +export function getReadingTime(content: PortableTextBlock[] | undefined): number { + const text = extractText(content); + const cjkCharacterCount = countCjkCharacters(text); + const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " ")); + const minutes = Math.ceil( + wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE, + ); + return Math.max(1, minutes); +} + +/** + * Format reading time for display + */ +export function formatReadingTime(minutes: number): string { + return `${minutes} min read`; +} diff --git a/potluck/src/utils/site-identity.ts b/potluck/src/utils/site-identity.ts new file mode 100644 index 0000000..857ffce --- /dev/null +++ b/potluck/src/utils/site-identity.ts @@ -0,0 +1,24 @@ +/** Resolved media reference from getSiteSettings() */ +export interface MediaReference { + mediaId: string; + alt?: string; + url?: string; +} + +export interface BlogSiteIdentitySettings { + title?: string; + tagline?: string; + logo?: MediaReference; + favicon?: MediaReference; +} + +const DEFAULT_SITE_TITLE = "My Blog"; +const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas."; + +export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) { + return { + siteTitle: settings?.title ?? DEFAULT_SITE_TITLE, + siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE, + siteLogo: settings?.logo?.url ? settings.logo : null, + }; +} diff --git a/potluck/src/worker.ts b/potluck/src/worker.ts new file mode 100644 index 0000000..670648c --- /dev/null +++ b/potluck/src/worker.ts @@ -0,0 +1,8 @@ +import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker"; + +export { PluginBridge }; + +export default { + ...handler, + scheduled: createScheduledHandler(), +} satisfies ExportedHandler; diff --git a/potluck/tsconfig.json b/potluck/tsconfig.json new file mode 100644 index 0000000..c2e1508 --- /dev/null +++ b/potluck/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "astro/tsconfigs/base", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src", ".astro/types.d.ts", "emdash-env.d.ts", "worker-configuration.d.ts"] +} diff --git a/potluck/wrangler.jsonc b/potluck/wrangler.jsonc new file mode 100644 index 0000000..8590566 --- /dev/null +++ b/potluck/wrangler.jsonc @@ -0,0 +1,25 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-emdash-site", + "main": "./src/worker.ts", + "compatibility_date": "2026-02-24", + "compatibility_flags": ["nodejs_compat"], + "d1_databases": [ + { + "binding": "DB", + "database_name": "my-emdash-site", + }, + ], + "r2_buckets": [ + { + "binding": "MEDIA", + "bucket_name": "my-emdash-media", + }, + ], + // Sandboxed plugins require Worker Loader, available on Workers paid plans. Uncomment to enable: + // "worker_loaders": [{ "binding": "LOADER" }], + // General maintenance cron trigger + "triggers": { + "crons": ["* * * * *"], + }, +}