Emdash source with visual editor image upload fix

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

700
AGENTS.md Normal file
View File

@@ -0,0 +1,700 @@
This file provides guidance to agentic coding tools when working with code in this repository.
## Project Status
**Beta, post pre-release.** EmDash is published to npm and in active use, with i18n, RTL, and the plugin system shipped. We're no longer in the scorched-earth pre-release phase -- real users depend on current behavior, so backwards compatibility now matters (see Rules below). All development happens inside this monorepo using `workspace:*` links. See [CONTRIBUTING.md](CONTRIBUTING.md) for the human-readable contributor guide (setup, repo layout, "build your own site" workflow).
## Repository Structure
This is a monorepo using pnpm workspaces.
`CLAUDE.md` is a symlink to `AGENTS.md`. `.opencode/skills` and `.claude/skills` are symlinks to `skills/`. Don't try to sync between them.
- **Root**: Workspace configuration and shared tooling
- **packages/core**: Main `emdash` package -- Astro integration, runtime, schema, API routes, CLI
- **packages/admin**: `@emdash-cms/admin` -- React admin UI shipped as a single mounted app under `/_emdash/admin/*`
- **packages/auth**: `@emdash-cms/auth` -- RBAC primitives (`Permissions`, `hasPermission`, `canActOnOwn`), passkey + magic link, RoleLevel ladder
- **packages/auth-atproto**: `@emdash-cms/auth-atproto` -- ATProto / Bluesky OAuth login
- **packages/blocks**: `@emdash-cms/blocks` -- shared Portable Text block defs and renderers
- **packages/cloudflare**: `@emdash-cms/cloudflare` -- D1/R2/Workers integration helpers
- **packages/marketplace**: `@emdash-cms/marketplace` -- plugin/theme marketplace client
- **packages/x402**: `@emdash-cms/x402` -- HTTP 402 payment middleware
- **packages/create-emdash**: `create-emdash` -- scaffolding CLI for new sites
- **packages/gutenberg-to-portable-text**: WordPress import helper
- **demos/**: Demo applications and examples (`demos/simple/` is the primary dev target)
- **templates/**: Starter templates (blog, marketing, portfolio, starter, blank) -- contributors copy these into `demos/` to build their own sites
- **docs/**: Public documentation site (Starlight)
# Rules
**Backwards compatibility matters now.** We're out of pre-release, but pre-1.0. Real installs depend on current behavior, schemas, and API shapes. Breaking changes are allowed in minors, but need an explicit decision, a bump on the affected package, and a changeset that calls the break out clearly. Prefer additive changes: new fields, new routes, new options with sensible defaults. If an old API is obsolete, mark the replacement as preferred and keep the old path working unless there's a reason it can't. Database migrations are forward-only -- never write one that leaves existing content inaccessible. When in doubt, open a Discussion before coding.
**TDD for bugs.** Write a failing test -> fix the bug -> verify the test passes. A bug without a reproducing test is not fixed.
**Localize everything user-facing.** All admin UI strings, aria labels, and toast messages go through Lingui. All admin layout uses RTL-safe logical Tailwind classes. See the Localization and RTL sections below.
## Contribution Rules (for AI agents and human contributors)
Read [CONTRIBUTING.md](CONTRIBUTING.md) before opening a PR. Key rules:
- **You MUST use the PR template.** Every PR must include the PR template with all sections filled out. The template is loaded automatically when you create a PR via the GitHub UI. If you create a PR via the API or CLI, copy the template from `.github/PULL_REQUEST_TEMPLATE.md` into the PR body. **PRs that do not use the template will be closed automatically by CI.**
- **Features require a prior approved Discussion.** Do not open a feature PR without one. It will be closed. Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) in the Ideas category first.
- **Bug fixes and docs** can be PRed directly.
- **Check every applicable checkbox** in the PR template, including the "I have read CONTRIBUTING.md" box and the AI disclosure box if any part of the code was AI-generated. Name the model(s)/tool(s) you used next to the disclosure box (e.g. Claude Opus 4.7, GPT-5.5, Cursor + Sonnet 4.6).
- **Do not make bulk/spray changes** (e.g., "fix all lint warnings", "add types everywhere", "improve error handling across codebase"). If you see a systemic issue, open a Discussion.
- **Do not touch code outside the scope of your change.** No drive-by refactors, no "while I'm here" improvements, no added comments or logging in unrelated files.
- **All CI checks must pass.** Typecheck, lint, format, and tests. No exceptions.
- **All non-trivial code changes should have an adversarial review.** Before opening the PR, perform cycles of adversarial review in a sub-agent, then fix, then re-review until no issues remain.
## Workflow
### Before Starting
1. Run `pnpm --silent lint:json | jq '.diagnostics | length'` and fix any issues. Non-negotiable.
### During Work
- Run `pnpm --silent lint:quick` after every edit -- takes less than a second. Returns JSON with stderr redirected to /dev/null, so it won't break parsers. Fix any issues immediately.
- Run `pnpm typecheck` (packages) or `pnpm typecheck:demos` (Astro demos) after each round of edits.
- Format regularly. pnpm format in the root uses oxfmt with tabs for indentation and is very fast. Don't let formatting pile up.
- Commit regularly, and always format and quick lint beforehand.
### Before Committing
You verified linting and types were clean before starting. If they're failing now, your changes caused it -- even if the errors are in files you didn't touch. Don't dismiss failures as "unrelated". Don't assign blame. Just fix them.
### Changesets
If your change affects a published package's behavior, add a changeset. Without one, the change won't trigger a package release.
```bash
pnpm changeset --empty
```
This creates a blank changeset file in `.changeset/`. Edit it to add the affected package(s), bump type, and description:
```markdown
---
"emdash": patch
---
Fixes CLI `--json` flag so JSON output is clean.
```
Start descriptions with a present-tense verb (Adds, Fixes, Updates, Removes, Refactors). Focus on what changes for the user, not implementation details.
Skip changesets for docs-only, test-only, CI, or demo/template changes.
See [CONTRIBUTING.md § Changesets](CONTRIBUTING.md#changesets) for full guidance and examples.
### PR Flow
1. All tests pass: `pnpm test`
2. Full lint suite clean: `pnpm --silent lint:json | jq '.diagnostics | length'`. Returns JSON with stderr piped to /dev/null, so it won't break parsers. Fix any issues.
3. Format with `pnpm format` (oxfmt with tabs for indentation, configured in `.prettierrc`).
4. Add a changeset if the change affects a published package: `pnpm changeset`.
5. Open the PR (via `gh pr create` or the GitHub UI). Fill out every section of the PR template -- copy `.github/PULL_REQUEST_TEMPLATE.md` into the body if using the API/CLI. Check the AI disclosure box if any code was AI-generated and name the model/tool you used.
## Architecture Overview
EmDash is an Astro-native CMS
### Core Architecture
- **Schema in the database.** `_emdash_collections` and `_emdash_fields` are the source of truth. Each collection gets a real SQL table (`ec_posts`, `ec_products`) with typed columns -- not EAV.
- **Middleware chain** (in order): runtime init -> setup check -> auth -> request context (ALS). Auth middleware handles authentication; individual routes handle authorization.
- **Handler layer** (`api/handlers/*.ts`) -- Business logic returns `ApiResponse<T>` (`{ success, data?, error? }`). Route files are thin wrappers that parse input, call handlers, and format responses.
- **Storage abstraction** -- `Storage` interface with `upload/download/delete/exists/list/getSignedUploadUrl`. Implementations: `LocalStorage` (dev), `S3Storage` (R2/AWS). Access via `emdash.storage` from locals.
### Known Quality Patterns
**Index discipline.** Every content table gets indexes on: `status`, `slug`, `created_at`, `deleted_at`, `scheduled_at` (partial -- `WHERE scheduled_at IS NOT NULL`), `live_revision_id`, `draft_revision_id`, `author_id`, `primary_byline_id`, `updated_at`, `locale`, `translation_group`. Foreign key columns always get an index. Naming: `idx_{table}_{column}` for single-column, `idx_{table}_{purpose}` for multi-column.
**API envelope consistency.** Handlers return `ApiResponse<T>` wrapping data in `{ success, data }`. List endpoints return `{ items, nextCursor? }` inside `data`. The admin client's `parseApiResponse` unwraps `body.data`. Be aware of this layering when adding new endpoints.
## Commands
### Root-level commands (run from repository root):
- `pnpm build` - Build all packages
- `pnpm test` - Run tests for all packages
- `pnpm check` - Run type checking and linting for all packages
- `pnpm format` - Format code using oxfmt
### Package-level commands (run within individual packages):
- `pnpm build` - Build the package using tsdown (ESM + DTS output)
- `pnpm dev` - Watch mode for development
- `pnpm test` - Run vitest tests
- `pnpm check` - Run publint and @arethetypeswrong/cli checks
## Key Files
| File | Purpose |
| ----------------------------------- | ----------------------------------------------------- |
| `src/live.config.ts` | Collection schemas + admin config (user's site) |
| `src/emdash-runtime.ts` | Central runtime; orchestrates DB, plugins, storage |
| `src/schema/registry.ts` | Manages `ec_*` table creation/modification |
| `src/database/migrations/runner.ts` | StaticMigrationProvider; register new migrations here |
| `src/plugins/manager.ts` | Loads and orchestrates trusted plugins |
## Code Patterns
### Database: Never Interpolate Into SQL
Kysely is the query builder. Use it properly:
- **Never** use `sql.raw()` with string interpolation or template literals containing variables.
- **Never** build SQL strings with `+` or backtick interpolation and pass them to `sql.raw()`.
- For **values**, use Kysely's `sql` tagged template: `` sql`SELECT * FROM t WHERE id = ${id}` `` -- interpolated values are automatically parameterized.
- For **identifiers** (table/column names), use `sql.ref()` which quotes them safely.
- If you absolutely must use `sql.raw()` for dynamic identifiers, validate them first with `validateIdentifier()` from `database/validate.ts` which asserts `/^[a-z][a-z0-9_]*$/`.
- The `json_extract(data, '$.${field}')` pattern is particularly dangerous -- always validate `field` before interpolation.
```typescript
// WRONG -- SQL injection via string interpolation
const query = `SELECT * FROM ${table} WHERE name = '${name}'`;
await sql.raw(query).execute(db);
// WRONG -- field name interpolated into sql.raw()
return sql.raw(`json_extract(data, '$.${field}')`);
// RIGHT -- parameterized value
await sql`SELECT * FROM ${sql.ref(table)} WHERE name = ${name}`.execute(db);
// RIGHT -- validated identifier in raw SQL
validateIdentifier(field);
return sql.raw(`json_extract(data, '$.${field}')`);
```
### API Routes: Use Shared Utilities
All API routes under `astro/routes/api/` must follow these patterns:
**Error responses** -- use `apiError()` from `api/error.ts`:
```typescript
// WRONG -- inline JSON.stringify with ad-hoc shape
return new Response(JSON.stringify({ error: "Not found" }), { status: 404 });
// RIGHT -- consistent shape: { error: { code, message } }
return apiError("NOT_FOUND", "Content not found", 404);
```
**Catch blocks** -- use `handleError()`, never expose `error.message` to clients:
```typescript
// WRONG -- leaks internal error details
catch (error) {
return new Response(JSON.stringify({
error: error instanceof Error ? error.message : "Unknown error"
}), { status: 500 });
}
// RIGHT -- logs internally, returns generic message
catch (error) {
return handleError(error, "Failed to update content", "CONTENT_UPDATE_ERROR");
}
```
**Input validation** -- use `parseBody()` / `parseQuery()` from `api/parse.ts`, never use `as` casts on `request.json()`:
```typescript
// WRONG -- no runtime validation, malformed input reaches the database
const body = (await request.json()) as CreateContentInput;
// RIGHT -- Zod validation, returns 400 on failure
const body = await parseBody(request, createContentSchema);
```
**Initialization checks** -- use a consistent message:
```typescript
if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500);
```
**Handler results** -- prefer the `unwrapResult()` helper over manual unwrapping:
```typescript
import { unwrapResult } from "#api/error.js";
// RIGHT -- one-liner; returns the right status from the error code automatically
const result = await handleContentGet(db, collection, id);
return unwrapResult(result);
// Manual unwrap is only needed when you want to do something between the
// success check and the response (e.g. set a custom header):
import { apiError, mapErrorStatus } from "#api/error.js";
if (!result.success) {
return apiError(result.error.code, result.error.message, mapErrorStatus(result.error.code));
}
return Response.json(result.data);
```
Note the function is named `mapErrorStatus`, not `mapErrorToStatus`.
### API Routes: Authorization
Every route that modifies state must check authorization. The auth middleware only checks authentication (is the user logged in); individual routes must check **permissions**.
Authorization is permission-based, not role-based. The `Permissions` map in `@emdash-cms/auth` (see `packages/auth/src/rbac.ts`) lists every gate -- `"content:read_drafts"`, `"content:edit_own"`, `"content:edit_any"`, `"schema:manage"`, `"media:upload"`, etc. -- and binds each to a minimum `RoleLevel`. Roles still exist as the underlying ladder (SUBSCRIBER < CONTRIBUTOR < AUTHOR < EDITOR < ADMIN), but route code never references them directly.
Use the helpers from `#api/authorize.js`:
```typescript
import { requirePerm, requireOwnerPerm } from "#api/authorize.js";
// Simple permission check -- use for any-actor capabilities (settings, schema, etc.)
const denied = requirePerm(user, "schema:manage");
if (denied) return denied;
// Ownership-aware check -- use for resources where authors can act on their own
// but only editors can act on anyone else's. Pass the resource owner's id and
// both the "own" and "any" permissions.
const denied = requireOwnerPerm(user, post.authorId, "content:edit_own", "content:edit_any");
if (denied) return denied;
```
`requirePerm` returns `null` on success, or a `Response` (401 if unauthenticated, 403 if authorized but missing the permission) that you should return directly. Same shape for `requireOwnerPerm`.
To find the right permission string for a new endpoint, scan `packages/auth/src/rbac.ts`. If no existing permission fits, add one there with a sensible minimum role -- this is the authoritative list. Never invent a permission string in a route file.
### API Routes: CSRF Protection
All state-changing endpoints (POST/PUT/DELETE) require the `X-EmDash-Request: 1` header, enforced by auth middleware. The admin UI and visual editing client send this header automatically. Do not add GET handlers for state-changing operations.
### Pagination
All list endpoints must use cursor-based pagination with a consistent shape:
```typescript
// Return type for all list queries
interface FindManyResult<T> {
items: T[];
nextCursor?: string;
}
```
- Use `encodeCursor(orderValue, id)` / `decodeCursor(cursor)` utilities.
- Default limit: 50. Maximum limit: 100. Always clamp.
- The response array key is always `items` (not `results`, not a bare array).
- Never return a bare array from a list endpoint -- always wrap in `{ items, nextCursor? }`.
### Adding Database Tables or Columns
When creating tables or adding columns queried in WHERE or ORDER BY clauses, add indexes. Check existing patterns in `database/migrations/` and `schema/registry.ts`. Foreign key columns should always have an index.
Index naming: `idx_{table}_{column}` for single-column, `idx_{table}_{purpose}` for multi-column. Content tables get standard indexes on `status`, `slug`, `created_at`, `deleted_at`, `author_id`, and all foreign key columns.
### Migrations
Migrations live in `packages/core/src/database/migrations/`. Conventions:
- **Naming:** `NNN_descriptive_name.ts` -- zero-padded 3-digit sequential number.
- **Exports:** Each migration exports `up(db: Kysely<unknown>)` and `down(db: Kysely<unknown>)`.
- **System tables** use Kysely's schema builder (`db.schema.createTable(...)`).
- **Dynamic content tables** (`ec_*`) use `sql` tagged templates with `sql.ref()` for identifiers.
- **Column types:** SQLite types -- `"text"`, `"integer"`, `"real"`, `"blob"`. Booleans are `"integer"` with `defaultTo(0)`. Timestamps are `"text"` with ``defaultTo(sql`(datetime('now'))`)``. IDs are `"text"` primary keys (ULIDs from `ulidx`).
- **Index naming:** `idx_{table}_{column}` for single-column, `idx_{table}_{purpose}` for multi-column.
- **Foreign keys** must always have an accompanying index.
- **Registration:** Migrations are statically imported in `database/migrations/runner.ts` and added to the `StaticMigrationProvider`. They are NOT auto-discovered -- this is required for Workers bundler compatibility. When adding a migration: (1) create the file, (2) add a static import in `runner.ts`, (3) add it to `getMigrations()`.
- **Multi-table migrations:** When altering all content tables, query `_emdash_collections` to discover `ec_*` tables and loop. See `013_scheduled_publishing.ts` for the pattern.
### API Route Structure
Route files live in `packages/core/src/astro/routes/api/`. Conventions:
- Every route file starts with `export const prerender = false;`.
- Handlers are named exports: `export const GET: APIRoute`, `export const POST: APIRoute`, etc.
- Handlers destructure from the Astro context: `({ params, request, url, locals })`.
- Access the CMS runtime via `const { emdash } = locals;`.
- Access the user via `const user = (locals as { user?: User }).user;`.
- URL structure mirrors file structure: `content/[collection]/index.ts` for list/create, `content/[collection]/[id].ts` for get/update/delete, with sub-actions as siblings: `[id]/publish.ts`, `[id]/schedule.ts`.
- **Never** add GET handlers for state-changing operations.
### Handler Layer
Handlers in `api/handlers/*.ts` contain business logic. Routes should be thin wrappers.
- Handlers are standalone async functions (not class methods).
- First parameter is always `db: Kysely<Database>`, followed by route-specific params.
- Always return `ApiResponse<T>` -- the `{ success, data?, error? }` discriminated union from `api/types.ts`.
- Entire body wrapped in try/catch. Errors return `{ success: false, error: { code, message } }`.
- Error codes are `SCREAMING_SNAKE_CASE`: `NOT_FOUND`, `VALIDATION_ERROR`, `CONTENT_CREATE_ERROR`, etc.
### Performance: caching and query patterns
EmDash runs on D1 with the Sessions API. Anonymous reads go to the nearest replica; writes and authenticated reads route to the primary. The primary is thousands of miles from some CF colos -- every round-trip matters, especially on cold isolates.
A few rules and patterns cover 90% of the footguns.
**Always add requestCached to query helpers called from templates.** Page-level template code runs inside the ALS request context, so the per-request cache (`src/request-cache.ts`) deduplicates identical calls within a single render. A single un-cached helper called from three widgets turns into three primary-routed reads on a page that should have made one. Rule of thumb: if a helper takes stable arguments (slug, key, entry ID) and can be called from multiple components, wrap it.
```typescript
// WRONG — every caller re-queries
export async function getSiteSetting(key: string) {
const db = await getDb();
return db.selectFrom("options").where("name", "=", key)...
}
// RIGHT — shared within one render
export function getSiteSetting(key: string) {
return requestCached(`siteSetting:${key}`, async () => {
const db = await getDb();
return ...;
});
}
```
The cache key must include every argument that changes the result. Missing an argument means wrong values get served; including too much just means more cache misses.
`requestCached` caches the _promise_, so concurrent callers share the in-flight query. On error the entry is deleted so the next call retries.
**Module-scope singletons must live on `globalThis`.** Vite duplicates modules across chunks during SSR bundling. A plain `let cache: X | null = null` in a module becomes _two_ variables if two chunks inline the module -- defeating the singleton. Use a `Symbol.for` key on `globalThis`, as `request-context.ts` does. See also `packages/core/src/settings/index.ts` (`SITE_SETTINGS_CACHE_KEY` / `holder`) for the pattern applied to a versioned cache, and `packages/core/src/request-cache.ts` for the per-request variant. The fix cut ~2 cold-start queries per D1 isolate.
**Prefer the batch query to a "has any" probe.** Adding a `SELECT id FROM foo LIMIT 1` before a batch query to skip it on empty sites trades one extra query on every real request for saving one query on sites that almost never exist. On live sites the batch query returns empty at the same cost; handle missing tables with an `isMissingTableError` catch.
**Defer bookkeeping past the response with `after(fn)`.** Maintenance writes (cron recovery, audit log flushes) don't need to block TTFB. `after(fn)` hands the promise to workerd's `waitUntil` when available, or fire-and-forgets on Node. Errors are caught and logged with the `[emdash]` prefix -- add your own `try/catch` inside `fn` with a module-specific prefix (e.g. `[cron]`) for better grep-ability. Deferred writes still happen; they just don't gate the response.
```typescript
import { after } from "emdash";
after(async () => {
try {
await recoverStaleLocks();
} catch (error) {
console.error("[cron] recovery failed:", error);
}
});
```
**One query beats two whenever possible.** Every query pays a round-trip to the replica (and the primary for writes). If you're fetching parent + children, use a `LEFT JOIN`. If you're fetching related records by a list of IDs, batch with `WHERE id IN (...)` -- but chunk at `SQL_BATCH_SIZE` (from `utils/chunks.ts`) to stay below D1's bind-parameter limit.
**Every new helper gets a query-count impact check.** The fixture harness (`pnpm query-counts`, see `scripts/query-counts.mjs`) builds `fixtures/perf-site/` and records per-route query counts in `scripts/query-counts.snapshot.{sqlite,d1}.json`. CI auto-updates the snapshots on PRs; review the diff. Fewer queries on a route is always the right direction. More requires a conversation.
### Admin UI: Use Kumo Components
The admin UI is built on [Kumo](https://github.com/cloudflare/kumo) (Cloudflare's design system). Use Kumo components for all standard UI elements -- never roll your own buttons, inputs, dialogs, selects, etc. This gives us consistent styling, dark mode, accessibility, and RTL support for free.
**Look up component docs from the CLI** -- don't guess at props:
```bash
npx @cloudflare/kumo doc Button # docs for a specific component
npx @cloudflare/kumo ls # list all available components
npx @cloudflare/kumo docs # docs for everything
```
Key components (all from `@cloudflare/kumo`):
- **`Button`** -- all clickable actions. Supports `variant`, `size`, `icon`, and `loading`.
- **`LinkButton`** -- anchor styled as a button. Use for navigation, never `<a>` with manual styling. Supports `external` prop for new-tab links.
- **`Dialog`** -- all modals. Use `ConfirmDialog` (ours) for simple confirm/cancel flows.
- **`Input`**, **`InputArea`**, **`Select`**, **`Checkbox`**, **`Switch`** -- form controls.
- **`Toast`** / **`Toasty`** -- notifications.
- **`Loader`** -- loading spinners.
- **`Badge`** -- status labels, counts.
- **`Popover`**, **`Dropdown`**, **`Tooltip`** -- overlays.
- **`CommandPalette`** -- the admin command palette.
- **`Label`** -- form labels (pairs with inputs).
```typescript
import { Button, LinkButton, Loader } from "@cloudflare/kumo";
// loading prop -- shows spinner and disables interaction automatically
<Button variant="primary" loading={mutation.isPending}>
{t`Save`}
</Button>
// icon prop with conditional Loader -- use when the icon itself changes per state
// (e.g. different icons for idle/pending/done -- see SaveButton.tsx for the full pattern)
<Button
variant={isSaved ? "secondary" : "primary"}
icon={isSaving ? <Loader size="sm" /> : isSaved ? <Check /> : <FloppyDisk />}
disabled={isSaving || isSaved}
aria-busy={isSaving}
>
{isSaving ? t`Saving...` : isSaved ? t`Saved` : t`Save`}
</Button>
// icon prop -- pass a Phosphor icon component or React element
<Button variant="secondary" icon={PlusIcon}>{t`Add item`}</Button>
// icon-only buttons require shape + aria-label
<Button shape="square" icon={TrashIcon} aria-label={t`Delete`} variant="ghost" />
// LinkButton for navigation -- never use <a> with manual button classes
<LinkButton href="/_emdash/admin" variant="ghost" icon={HouseIcon}>
{t`Dashboard`}
</LinkButton>
// external links open in new tab with rel="noopener noreferrer"
<LinkButton href="https://docs.example.com" external>{t`Docs`}</LinkButton>
```
**Styling rules:**
- **Use semantic color tokens**, not raw Tailwind colors. `bg-kumo-brand` not `bg-blue-500`. `text-kumo-subtle` not `text-gray-500`. The full token list is in the Kumo styles.
- **Never use `dark:` prefixes.** Kumo's semantic tokens use CSS `light-dark()` to handle dark mode automatically. If you're writing `dark:bg-something`, you're bypassing the design system.
- Don't reach for raw HTML elements or Tailwind-only solutions when a Kumo component exists. If you need a button, use `Button`. If you need a link that looks like a button, use `LinkButton`. If you need `buttonVariants()` classes on a non-button element, import `buttonVariants` from `@cloudflare/kumo`.
### Admin UI: Localization (Lingui)
Every user-facing string in the admin UI goes through Lingui. No hard-coded English literals in JSX, attributes, or TypeScript strings that end up in the DOM.
- Catalogs live in `packages/admin/src/locales/{locale}/messages.po`. English is the source.
- Enabled locales are defined in `packages/admin/src/locales/locales.ts`.
- **Do not include `messages.po` changes in PRs that aren't translation PRs.** A workflow runs `pnpm locale:extract` on merge to `main` and commits the catalog updates. Including extracted PO changes in feature/bugfix PRs creates churn and merge conflicts (the line-number references shift on every edit). If you accidentally extracted, revert the `.po` files before opening the PR.
- Set `EMDASH_PSEUDO_LOCALE=1` in dev to render pseudo-localized text -- any untranslated English leaking through is immediately visible.
```typescript
import { useLingui } from "@lingui/react/macro";
import { Trans } from "@lingui/react/macro";
// Simple strings -- tagged template
function DeleteButton() {
const { t } = useLingui();
return (
<button type="button" aria-label={t`Delete post`}>
{t`Delete`}
</button>
);
}
// JSX with interpolation or nested components -- <Trans> macro
<Trans>
Published by <strong>{authorName}</strong> on {formattedDate}
</Trans>;
// Pluralization -- use plural macro
import { plural } from "@lingui/core/macro";
const label = plural(count, { one: "# item", other: "# items" });
// Module-scope constants -- use msg`` descriptors, resolve with t() inside component
import type { MessageDescriptor } from "@lingui/core";
import { msg } from "@lingui/core/macro";
interface BlockTransform {
id: string;
label: MessageDescriptor;
}
const blockTransforms: BlockTransform[] = [
{ id: "paragraph", label: msg`Paragraph` },
{ id: "heading1", label: msg`Heading 1` },
];
function BlockMenu() {
const { t } = useLingui();
return blockTransforms.map((b) => <span key={b.id}>{t(b.label)}</span>);
}
```
Common mistakes to avoid:
- **Bare string literals in JSX**: `<button>Save</button>` -- must be `<button>{t\`Save\`}</button>`or`<button><Trans>Save</Trans></button>`.
- **Unwrapped aria labels, titles, placeholders, alt text**: these are user-facing too. `aria-label="Close"` -> ``aria-label={t`Close`}``.
- **Concatenating translated pieces**: ``t`Hello ` + name`` breaks word order in other languages. Use `` t`Hello ${name}` `` or `<Trans>`.
- **Calling `t` at module scope**: the locale isn't bound yet. Use `msg` to create a `MessageDescriptor`, then resolve it with `t(descriptor)` inside the component. Type the constant as `MessageDescriptor` (from `@lingui/core`).
- **Reusing the same key for different meanings**: give them distinct messages or use context.
Server-side (API error messages): still English-only for now. Keep error codes stable (`SCREAMING_SNAKE_CASE`) -- the admin UI maps codes to localized messages client-side.
### Admin UI: RTL-safe Tailwind
The admin supports RTL locales. Use logical Tailwind classes, never physical ones. An LTR-only class that slips in will misplace UI in Arabic.
| Use | Not |
| -------------------------------------------- | ----------------------------- |
| `ms-*` / `me-*` (margin-inline-start/end) | `ml-*` / `mr-*` |
| `ps-*` / `pe-*` (padding-inline-start/end) | `pl-*` / `pr-*` |
| `start-*` / `end-*` (inset-inline-start/end) | `left-*` / `right-*` |
| `text-start` / `text-end` | `text-left` / `text-right` |
| `border-s` / `border-e` | `border-l` / `border-r` |
| `rounded-s-*` / `rounded-e-*` | `rounded-l-*` / `rounded-r-*` |
| `float-start` / `float-end` | `float-left` / `float-right` |
For icons that indicate direction (chevrons, arrows, back/forward), flip them in RTL. Use `rtl:-scale-x-100` on the icon, or pick a bidi-aware icon. Don't rely on the icon being visually neutral.
`LocaleDirectionProvider` (`packages/admin/src/locales/LocaleDirectionProvider.tsx`) syncs `document.documentElement.dir` and `lang` with the active locale. You don't need to set these manually.
**Always test new admin UI in Arabic** before considering it done. Switch the locale in the admin and walk through the feature. Broken directionality is the single most common i18n regression.
### Content Localization
Content tables use a row-per-locale model (see migration `019_i18n.ts`):
- Every `ec_*` table has a `locale` column (defaults to `'en'`) and a `translation_group` ULID shared across translations of the same piece of content.
- Slug uniqueness is `UNIQUE(slug, locale)` -- not global. Two posts can share a slug across locales.
- Any new query against a content table must filter by `locale` or deliberately operate across locales (e.g. the translations endpoint). Forgetting the filter is a correctness bug, not a style issue.
- Indexes exist on both `locale` and `translation_group`. Use them.
- Fetch all translations of a single piece via `GET /_emdash/api/content/{collection}/{id}/translations`.
When adding new content-table features (new columns, new filters, new list endpoints), ask: does this need to be per-locale or per-translation-group? Per-locale is usually correct for display fields; per-group is correct for anything identifying "the same thing" across languages (e.g. comment counts, view counts might aggregate across the group).
### Admin UI: API Error Handling
All admin API functions use `throwResponseError()` from `lib/api/client.ts` to surface server error messages to the user. Never throw a generic error when the response body contains a message.
```typescript
import { apiFetch, throwResponseError } from "./client.js";
// WRONG -- loses the server's error message
if (!response.ok) throw new Error("Failed to create term");
// WRONG -- manually parsing what throwResponseError already does
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error?.message || "Failed to create term");
}
// RIGHT -- parses { error: { message } } body, falls back to generic message
if (!response.ok) await throwResponseError(response, "Failed to create term");
```
### Admin UI: Confirmation Dialogs
Use `ConfirmDialog` from `components/ConfirmDialog.tsx` for all confirmation modals (delete, disable, demote, etc.). Pass `mutation.error` directly -- don't manage error state manually.
```typescript
import { ConfirmDialog } from "./ConfirmDialog.js";
<ConfirmDialog
open={!!deleteSlug}
onClose={() => { setDeleteSlug(null); deleteMutation.reset(); }}
title="Delete Section?"
description="This will permanently delete the section."
confirmLabel="Delete"
pendingLabel="Deleting..."
isPending={deleteMutation.isPending}
error={deleteMutation.error}
onConfirm={() => deleteMutation.mutate(deleteSlug)}
/>
```
### Admin UI: Inline Dialog Errors
For form dialogs and other cases where `ConfirmDialog` doesn't fit, use `DialogError` and `getMutationError()` from `components/DialogError.tsx`:
```typescript
import { DialogError, getMutationError } from "./DialogError.js";
// In JSX -- renders nothing when message is null
<DialogError message={getMutationError(createMutation.error)} />
// With local error state fallback (e.g. client-side validation)
<DialogError message={localError || getMutationError(mutation.error)} />
```
Don't duplicate the error banner styling inline -- always use `DialogError`.
### Import Conventions
- **Internal imports** always use `.js` extensions (ESM requirement):
```typescript
import { ContentRepository } from "../../database/repositories/content.js";
```
- **Type-only imports** must use `import type` (enforced by `verbatimModuleSyntax: true`):
```typescript
import type { Kysely } from "kysely";
```
- **Package imports** do not use extensions: `import { sql } from "kysely"`.
- **Virtual modules** use `// @ts-ignore` comment:
```typescript
// @ts-ignore - virtual module
import virtualConfig from "virtual:emdash/config";
```
- **Barrel files** (`index.ts`) re-export from sub-modules. Separate `export type { ... }` from value exports.
### Environment Gating
- **Dev-only endpoints** must check `import.meta.env.DEV` and return 403 if false. This is a compile-time constant -- it cannot be spoofed at runtime.
- **Never** use `process.env.NODE_ENV` -- always use `import.meta.env.DEV` or `import.meta.env.PROD` (Vite/Astro standard).
- **Secrets** follow the pattern: `import.meta.env.EMDASH_X || import.meta.env.X || ""` -- check prefixed name first, then generic, then fallback.
### Cloudflare Env
To access the Cloudflare `env` object, import it directly from `"cloudflare:workers"` -- no need to access it from the context in a handler. This is a virtual module that resolves to the correct bindings for the current environment, whether that's a Worker or a local dev environment.
Do not manually type the Cloudflare Env object. When in a Worker context, run `pnpm wrangler types` to generate `worker-configuration.d.ts` with the correct bindings for the current environment. This includes types for bindings in wrangler.jsonc as well as secrets in `.dev.vars`. Regenerate it if you edit the bindings. Ensure it is referenced in `tsconfig.json` under `include` and then the types will be available globally.
If not working in a Worker context, but in a library that will be used in a Worker, install `@cloudflare/workers-types` and reference it in `tsconfig.json` under `compilerOptions.types`. This will allow you to use Cloudflare-specific types like `R2Bucket` and `D1Database` in your code.
### Content Table Lifecycle
Dynamic content tables are managed by `SchemaRegistry` in `schema/registry.ts`:
- **Table names:** `ec_{collection_slug}` (e.g., `ec_posts`). System tables: `_emdash_{name}`.
- **Slug validation:** `/^[a-z][a-z0-9_]*$/`, max 63 chars. Checked against `RESERVED_COLLECTION_SLUGS` and `RESERVED_FIELD_SLUGS`.
- **Standard columns:** Every content table gets `id`, `slug`, `status`, `author_id`, `created_at`, `updated_at`, `published_at`, `scheduled_at`, `deleted_at`, `version`, `live_revision_id`, `draft_revision_id`. User-defined field columns are added via `ALTER TABLE`.
- **Field type mapping:** `FIELD_TYPE_TO_COLUMN` (in `schema/types.ts`) maps each field type to a SQL column type. Most string-shaped types (string, text, datetime, image, file, reference, slug, url, select) map to TEXT; number to REAL; integer/boolean to INTEGER; portableText/json/multiSelect to JSON. Check the map directly when adding a new field type.
- **Orphan discovery:** `discoverOrphanedTables()` finds `ec_*` tables without matching `_emdash_collections` entries. This is used for recovering from crashes during schema changes.
### Testing
- **Framework:** vitest. Tests in `packages/core/tests/`.
- **Database:** Tests use real databases, never mocks. SQLite (`better-sqlite3`) for the default in-memory case; PostgreSQL via a real `pg` connection with per-test schema isolation for parity tests of dialect-sensitive code (set `PG_CONNECTION_STRING` to opt in).
- **Utilities:** `tests/utils/test-db.ts` provides:
- SQLite: `createTestDatabase()`, `setupTestDatabase()` (runs migrations), `setupTestDatabaseWithCollections()` (migrations + standard post/page collections), `teardownTestDatabase()`
- Postgres: `setupTestPostgresDatabase()`, `setupTestPostgresDatabaseWithCollections()`, `teardownTestPostgresDatabase()`
- Dialect-agnostic: `setupForDialect(dialect)`, `setupForDialectWithCollections(dialect)`, `teardownForDialect(ctx)`, plus a `describeEachDialect(name, fn)` wrapper that runs the same test suite against each dialect. Use this for any code that builds queries -- regressions tend to be SQLite-only or Postgres-only.
- **Structure:** `tests/unit/` for unit, `tests/integration/` for integration (real DB), `tests/e2e/` for Playwright. Test files mirror source structure.
- **Lifecycle:** Each test gets a fresh DB in `beforeEach`, destroyed in `afterEach`.
### URL and Redirect Handling
When accepting redirect URLs from query params or request bodies:
- Validate the URL starts with `/` (relative path only).
- Reject URLs starting with `//` (protocol-relative -- would redirect to external hosts).
- HTML-escape any URL values before interpolating into HTML responses.
- Prefer server-side `Response.redirect()` over HTML `<meta http-equiv="refresh">`.
## Toolchain
- **pnpm** -- package manager
- **tsdown** -- TypeScript builds (ESM + DTS)
- **vitest** -- testing
- **oxfmt** -- code formatting (tabs for indentation). All source files use tabs, not spaces.
## TypeScript Configuration
- Target: ES2023
- Module: preserve (for bundler compatibility)
- Strict mode with `noUncheckedIndexedAccess`, `noImplicitOverride`, `verbatimModuleSyntax`
## Dev Bypass for Browser Testing
EmDash uses passkey authentication which cannot be automated in browser tests. Two dev-only endpoints are available to bypass authentication:
### Setup Bypass
Skips the setup wizard, runs migrations, creates a dev admin user, and establishes a session:
```
GET /_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
```
### Auth Bypass
Creates a session for the dev admin user (assumes setup is already complete):
```
GET /_emdash/api/auth/dev-bypass?redirect=/_emdash/admin
```
### Usage in Agent Browser
When testing the admin UI with agent-browser, navigate to the setup bypass URL first:
```typescript
await page.goto("http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin");
```
This will:
1. Run database migrations
2. Create a dev admin user (`dev@emdash.local`)
3. Set up a session cookie
4. Redirect to the admin dashboard
**Note**: These endpoints only work when `import.meta.env.DEV` is true. They return 403 in production.

328
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,328 @@
# Contributing to EmDash
> **Beta.** EmDash is published to npm. During development you work inside the monorepo — packages use `workspace:*` links, so everything "just works" without publishing.
## Prerequisites
- **Node.js** 22+
- **pnpm** 10+ (`corepack enable` if you don't have it)
- **Git**
## Quick Setup
```bash
git clone https://github.com/emdash-cms/emdash.git && cd emdash
pnpm install
pnpm build # build all packages (required before first run)
```
### Run the Demo
The `demos/simple/` app is the primary development target. It uses Node.js + SQLite — no Cloudflare account needed.
```bash
cd demos/simple
pnpm dev # http://localhost:4321
```
Open the admin at `http://localhost:4321/_emdash/admin`. The setup wizard runs automatically on first launch — it creates the database, runs migrations, and prompts you to create an admin account.
In dev mode, you can skip passkey auth with the dev bypass:
```
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
```
To populate the demo with sample content:
```bash
pnpm seed
```
### Run with Cloudflare (optional)
`demos/cloudflare/` runs on the real `workerd` runtime with D1. See its [README](demos/cloudflare/README.md) for setup.
### Developing Templates
Templates in `templates/` are workspace members and can be run directly:
```bash
cd templates/portfolio
pnpm bootstrap # first time — set up database and seed content
pnpm dev # run dev server
```
Available templates: `blog`, `portfolio`, `marketing`.
To start fresh, delete the database and re-bootstrap:
```bash
rm templates/portfolio/data.db
cd templates/portfolio && pnpm bootstrap
```
## Repository Layout
This is a pnpm monorepo. Here's what each directory is for:
| Directory | What it is | When you'd work here |
| ------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------ |
| `packages/core/` | The main `emdash` package — Astro integration, REST API, database, schema management, plugins | Most core development |
| `packages/admin/` | React SPA for the admin UI (`@emdash-cms/admin`) | Admin UI changes, translations |
| `packages/auth/` | Authentication — passkeys, OAuth, magic links (`@emdash-cms/auth`) | Auth flow changes |
| `packages/cloudflare/` | Cloudflare Workers adapter + plugin sandbox (`@emdash-cms/cloudflare`) | Cloudflare-specific features |
| `packages/blocks/` | Portable Text block definitions (`@emdash-cms/blocks`) | Content block types |
| `packages/create-emdash/` | `create-emdash` CLI scaffolder | Project scaffolding |
| `packages/plugins/` | First-party plugins (each subdirectory is a package) | Plugin development |
| `demos/simple/` | Primary dev/test app (Node.js + SQLite) | Running and testing locally |
| `demos/cloudflare/` | Cloudflare Workers demo (D1) | Testing on CF runtime |
| `templates/` | Starter templates (blog, portfolio, marketing + CF variants) | Template development |
| `docs/` | Documentation site (Starlight) | Docs changes |
| `e2e/` | Playwright test fixtures | E2E test infrastructure |
| `i18n/` | Translation status dashboard (Lunaria) | Translation tracking |
## Development Workflow
### Watch Mode
For iterating on core packages alongside the demo, run two terminals:
```bash
# Terminal 1 — rebuild packages/core on change
cd packages/core && pnpm dev
# Terminal 2 — run the demo
cd demos/simple && pnpm dev
```
Changes to `packages/core/src/` will be picked up by the demo's dev server automatically.
### Checks
Run these from the repo root before committing:
```bash
pnpm typecheck # TypeScript (packages)
pnpm lint # full type-aware lint
pnpm format # auto-format with oxfmt (tabs, not spaces)
```
Type checking **must** pass. Lint **must** pass. Don't commit with known failures.
### Tests
```bash
pnpm test # all packages
cd packages/core && pnpm test # core only
cd packages/core && pnpm test --watch # watch mode
pnpm test:e2e # Playwright (starts its own server)
```
Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.
### Building Your Own Site (Inside the Monorepo)
Copy a template into `demos/`, give it a unique `name` in `package.json`, run `pnpm install`, and start developing:
```bash
cp -r templates/blog demos/my-site
# edit demos/my-site/package.json to set a unique name
pnpm install
cd demos/my-site && pnpm dev
```
Your site uses `workspace:*` links to the local packages, so core changes are reflected immediately (with watch mode).
## Key Architectural Concepts
- **Schema lives in the database**, not in code. `_emdash_collections` and `_emdash_fields` are the source of truth.
- **Real SQL tables** per collection (`ec_posts`, `ec_products`), not EAV.
- **Kysely** for all queries. Never interpolate into SQL — see `AGENTS.md` for the full rules.
- **Handler layer** (`api/handlers/*.ts`) holds business logic. Route files are thin wrappers.
- **Middleware chain**: runtime init → setup check → auth → request context.
## Adding a Migration
1. Create `packages/core/src/database/migrations/NNN_description.ts` (zero-padded sequence number).
2. Export `up(db)` and `down(db)` functions.
3. **Register it** in `packages/core/src/database/migrations/runner.ts` — migrations are statically imported, not auto-discovered (Workers bundler compatibility).
## Adding an API Route
1. Create the file in `packages/core/src/astro/routes/api/`.
2. Start with `export const prerender = false;`.
3. Use `apiError()`, `handleError()`, `parseBody()` from `#api/`.
4. Check authorization with `requirePerm()` on all state-changing routes.
5. Register the route in `packages/core/src/astro/integration/routes.ts`.
## Internationalization (i18n)
The admin UI is translatable using [Lingui](https://lingui.dev). All user-visible strings in `packages/admin/src/` should be wrapped for translation.
### Making strings translatable
Use the `t` tagged template for plain strings and `<Trans>` for strings containing JSX:
```tsx
import { Trans, useLingui } from "@lingui/react/macro";
function MyComponent() {
const { t } = useLingui();
return (
<div>
{/* Plain strings */}
<h1>{t`Settings`}</h1>
<label>{t`Email address`}</label>
{/* Strings with interpolation */}
<p>{t`Authentication error: ${error}`}</p>
{/* Strings containing JSX elements */}
<p>
<Trans>
Don't have an account? <a href="/signup">Sign up</a>
</Trans>
</p>
</div>
);
}
```
**Don't include `messages.po` changes in feature or bugfix PRs.** A workflow runs `pnpm locale:extract` on merge to `main` and commits the catalog updates automatically. Including extracted PO changes in a non-translation PR creates churn and merge conflicts, since the line-number references in the catalogs shift on every edit. If you ran extraction locally and ended up with `.po` changes, revert them before opening the PR.
Translation PRs are the exception — see [Translating EmDash](https://docs.emdashcms.com/contributing/translating/).
### What to wrap
- Button labels, headings, descriptions, error messages, placeholder text — anything a user reads.
- Don't wrap: log messages, developer-facing errors, HTML attributes that aren't user-visible, or strings that are the same in every language (brand names, URLs). Do wrap `aria-label` when it labels an interactive control, because screen readers announce it to users. For decorative elements, avoid `aria-label` and use `aria-hidden="true"` instead.
For the full translation contributor guide, see [Translating EmDash](https://docs.emdashcms.com/contributing/translating/).
## Contribution Policy
### What we accept
| Type | Process |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Bug fixes** | Open a PR directly. Include a failing test that reproduces the bug. |
| **Docs / typos** | Open a PR directly. |
| **Translations** | Open a PR directly. See [Translating EmDash](https://docs.emdashcms.com/contributing/translating/). |
| **Features** | Open a [Discussion](https://github.com/emdash-cms/emdash/discussions/categories/ideas) and wait for a maintainer to approve it. |
| **Refactors** | Open a Discussion first. Refactors are opinionated and need alignment. |
| **Performance** | Open a Discussion first with benchmarks showing the improvement. |
**Feature PRs without prior maintainer approval will be closed.** This isn't about gatekeeping — it's about not wasting your time on work that might not align with the project's direction. Open a Discussion, let us talk it through, and wait for a maintainer to give the go-ahead before writing code.
### AI-generated PRs
We welcome AI-assisted contributions. They are held to the same quality bar as any other PR:
- The submitter is responsible for the code's correctness, not the AI tool.
- AI-generated PRs must pass all CI checks, follow the project's code patterns, and include tests.
- The PR template has an AI disclosure checkbox — please check it and name the model/tool you used (e.g. Claude Opus 4.7, GPT-5.5, Cursor + Sonnet 4.6). This isn't punitive; it helps reviewers pay extra attention to edge cases that AI tools commonly miss, and lets them run the review pass with a different model family — different families have different blind spots.
- Bulk/spray PRs across the repo (e.g., "fix all lint warnings", "add types everywhere") will be closed. If you see a pattern worth fixing, open a Discussion first.
### What we don't accept
- **Drive-by feature additions.** If there's no Discussion, there's no PR.
- **Speculative refactors** that don't solve a concrete problem.
- **Dependency upgrades** outside of Renovate/Dependabot. We manage these centrally.
- **"Improvements"** to code you haven't been asked to change (added logging, extra error handling, style changes in unrelated files).
## Changesets
Every PR that changes the behavior of a published package needs a **changeset** — a small Markdown file that describes the change for the CHANGELOG and determines the version bump. Without a changeset, the change won't trigger a package release.
### When you need one
- Bug fixes, features, refactors, or any other change that affects a published package's behavior or API.
- Changes that span multiple packages need one changeset listing all affected packages.
- If a PR makes more than one distinct change, add a separate changeset for each. Each one becomes its own CHANGELOG entry.
### When you don't
- Docs-only changes, test-only changes, CI/tooling changes, or changes to demo apps and templates (these are in the changeset ignore list).
### How to add one
Run from the repo root:
```bash
pnpm changeset
```
This walks you through selecting the affected package(s), the semver bump type, and a description. It creates a randomly-named `.md` file in `.changeset/`.
You can also create one manually — see the existing files in `.changeset/` for the format.
### Writing the description
Start with a present-tense verb describing what the change does, as if completing "This PR...":
- **Adds** — a new feature or capability
- **Fixes** — a bug fix
- **Updates** — an enhancement to existing behavior
- **Removes** — removed functionality
- **Refactors** — internal restructuring with no behavior change
Focus on how the change affects someone **using** the package, not implementation details. The description ends up in the CHANGELOG, which people read once during upgrades.
**Patch** (bug fixes, refactors, small improvements):
```markdown
---
"emdash": patch
---
Fixes CLI `--json` flag so JSON output is clean. Log messages now go to stderr when `--json` is set.
```
**Minor** (new features, non-breaking additions):
```markdown
---
"emdash": minor
---
Adds `scheduled_at` field to content entries, enabling scheduled publishing via the admin UI.
```
**Major** (breaking changes) — include migration guidance:
```markdown
---
"emdash": major
---
Removes the `legacyAuth` option from the integration config. All sites must use passkey authentication.
To migrate, remove `legacyAuth: true` from your `emdash()` config in `astro.config.mjs`.
```
### Which packages?
Only published packages need changesets. Demos, templates, docs, and test fixtures are excluded. The main packages are:
- `emdash` (core)
- `@emdash-cms/admin`, `@emdash-cms/auth`, `@emdash-cms/cloudflare`, `@emdash-cms/blocks`
- `create-emdash`
- First-party plugins (`@emdash-cms/plugin-*`)
When in doubt, run `pnpm changeset` and it will only show packages that aren't ignored.
## Commits and PRs
- Branch from `main`.
- Commit messages: describe _why_, not just _what_.
- Fill out the PR template completely. PRs with an empty template will be closed.
- Ensure `pnpm typecheck` and `pnpm lint` pass before pushing.
- Run relevant tests.
## Getting Help
- Read `AGENTS.md` for architecture and code patterns
- Check the [documentation site](https://docs.emdashcms.com) for guides and API reference
- Open an issue or ask in the chat

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@10.28.0 --activate
WORKDIR /app
# ---- Install dependencies ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY packages/ packages/
COPY templates/ templates/
COPY demos/ demos/
COPY docs/package.json docs/package.json
COPY e2e/fixture/package.json e2e/fixture/package.json
RUN sed -i '/slidev/d' pnpm-workspace.yaml
RUN pnpm install --frozen-lockfile
# ---- Build ----
FROM deps AS build
COPY . .
RUN sed -i '/slidev/d' pnpm-workspace.yaml
RUN sed -i 's|file:./data.db|file:./data/data.db|' templates/blog/astro.config.mjs
RUN pnpm build && pnpm --filter @emdash-cms/template-blog build
# Bundle the blog template into a standalone deployment
RUN pnpm --filter @emdash-cms/template-blog deploy /deploy --prod --legacy
# Copy build output and seed data into the deploy directory
RUN cp -r /app/templates/blog/dist /deploy/dist
RUN cp -r /app/templates/blog/seed /deploy/seed
RUN cp /app/templates/blog/astro.config.mjs /deploy/astro.config.mjs
# ---- Runtime ----
FROM node:22-slim
WORKDIR /app
COPY --from=build /deploy .
RUN mkdir -p data uploads \
&& ln -s /app/node_modules/.pnpm/node_modules/kysely /app/node_modules/kysely
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

9
LICENSE Normal file
View File

@@ -0,0 +1,9 @@
The MIT License
Copyright 2026 Cloudflare Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

210
README.md Normal file
View File

@@ -0,0 +1,210 @@
# EmDash
A full-stack TypeScript CMS built on [Astro](https://astro.build/) and [Cloudflare](https://www.cloudflare.com/). EmDash takes the ideas that made WordPress dominant -- extensibility, admin UX, a plugin ecosystem -- and rebuilds them on serverless, type-safe foundations. Plugins run in sandboxed Worker isolates, solving the fundamental security problem with WordPress's plugin architecture.
## Get Started
> [!IMPORTANT]
> EmDash depends on Dynamic Workers to run secure sandboxed plugins. Dynamic Workers are currently only available on paid accounts. [Upgrade your account](https://www.cloudflare.com/plans/developer-platform/) (starting at $5/mo) or comment out the `worker_loaders` block of your `wrangler.jsonc` configuration file to disable plugins.
```bash
npm create emdash@latest
```
Or deploy directly to your Cloudflare account:
[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/emdash-cms/templates/tree/main/blog-cloudflare)
EmDash runs on Cloudflare (D1 + R2 + Workers) or any Node.js server with SQLite. No PHP, no separate hosting tier -- just deploy your Astro site.
## Templates
EmDash ships with three starter templates:
<table>
<tr>
<td width="33%" valign="top">
### Blog
<a href="assets/templates/blog/latest/"><img src="assets/templates/blog/latest/homepage-light-desktop.jpg" alt="Blog template" width="100%"></a>
A classic blog with sidebar widgets, search, and RSS.
- Categories & tags
- Full-text search
- Comment-ready
- RSS feed
- Dark / light mode
</td>
<td width="33%" valign="top">
### Marketing
<a href="assets/templates/marketing/latest/"><img src="assets/templates/marketing/latest/homepage-light-desktop.jpg" alt="Marketing template" width="100%"></a>
A conversion-focused landing page with pricing and contact form.
- Hero with CTAs
- Feature grid
- Pricing cards
- FAQ and contact form
- Dark / light mode
</td>
<td width="33%" valign="top">
### Portfolio
<a href="assets/templates/portfolio/latest/"><img src="assets/templates/portfolio/latest/work-light-desktop.jpg" alt="Portfolio template" width="100%"></a>
A visual portfolio for showcasing creative work.
- Project grid
- Tag filtering
- Case study pages
- RSS feed
- Dark / light mode
<br /><br />
</td>
</tr>
</table>
## Why EmDash?
**WordPress was built for a different era.** Running WordPress today means managing PHP alongside JavaScript, layering caches to get acceptable performance, and knowing that [96% of WordPress security vulnerabilities come from plugins](https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/). EmDash is what WordPress would look like if you started from scratch with today's tools.
**Sandboxed plugins.** WordPress plugins have full access to the database, filesystem, and user data. A single vulnerable plugin can compromise the entire site. EmDash plugins run in isolated [Worker sandboxes](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/) via Dynamic Worker Loaders, each with a declared capability manifest. A plugin that requests `read:content` and `email:send` can do exactly that and nothing else.
```typescript
export default () =>
definePlugin({
id: "notify-on-publish",
capabilities: ["read:content", "email:send"],
hooks: {
"content:afterSave": async (event, ctx) => {
if (event.content.status !== "published") return;
await ctx.email.send({
to: "editors@example.com",
subject: `New post: ${event.content.title}`,
});
},
},
});
```
**Structured content, not serialized HTML.** WordPress stores rich text as HTML with metadata embedded in comments -- tying your content to its DOM representation. EmDash uses [Portable Text](https://www.portabletext.org/), a structured JSON format that decouples content from presentation. Your content can render as a web page, a mobile app, an email, or an API response without parsing HTML.
**Built for agents.** EmDash ships with agent skills for building plugins and themes, a CLI that lets agents manage content and schema programmatically, and a built-in [MCP server](https://modelcontextprotocol.io/) so AI tools like Claude and ChatGPT can interact with your site directly.
**Runs anywhere.** EmDash uses portable abstractions at every layer -- Kysely for SQL, S3 API for storage -- that work with SQLite, D1, Turso, PostgreSQL, R2, AWS S3, or local files. It runs best on Cloudflare, but it's not locked to it.
## How It Works
EmDash is an Astro integration. Add it to your config and you get a complete CMS: admin panel, REST API, authentication, media library, and plugin system.
```typescript
// astro.config.mjs
import emdash from "emdash/astro";
import { d1 } from "emdash/db";
export default defineConfig({
integrations: [emdash({ database: d1() })],
});
```
Content types are defined in the database, not in code. Non-developers create and modify collections through the admin UI. Each collection gets a real SQL table with typed columns. Developers generate TypeScript types from the live schema:
```bash
npx emdash types
```
Query content using Astro's Live Collections -- no rebuilds, no separate API:
```astro
---
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
{posts.map((post) => <article>{post.data.title}</article>)}
```
## Features
**Content** -- Blog posts, pages, custom content types. Rich text editing via TipTap with Portable Text storage. Revisions, drafts, scheduled publishing, full-text search (FTS5), inline visual editing.
**Admin** -- Full admin panel with visual schema builder, media library (drag-drop uploads via signed URLs), navigation menus, taxonomies, widgets, and a WordPress import wizard.
**Auth** -- Passkey-first (WebAuthn) with OAuth and magic link fallbacks. Role-based access control: Administrator, Editor, Author, Contributor.
**Plugins** -- `definePlugin()` API with lifecycle hooks, KV storage, settings, admin pages, dashboard widgets, custom block types, and API routes. Sandboxed execution on Cloudflare via Dynamic Worker Loaders.
**Agents** -- Skill files for AI-assisted plugin and theme development. CLI for programmatic site management. Built-in MCP server for direct AI tool integration.
**WordPress migration** -- Import posts, pages, media, and taxonomies from WXR exports, the WordPress REST API, or WordPress.com. Agent skills help port plugins and themes.
## Portable Platforms
| Layer | Cloudflare | Also works with |
| -------- | --------------------------- | --------------------------------------------------- |
| Database | D1 | SQLite, Turso/libSQL, PostgreSQL |
| Storage | R2 | AWS S3, any S3-compatible service, local filesystem |
| Sessions | KV | Redis, file-based |
| Plugins | Worker isolates (sandboxed) | In-process (safe mode) |
## Status
EmDash is in **beta preview**. We welcome contributions, feedback, plugins, themes, and ideas.
```bash
npm create emdash@latest
```
See the [documentation](https://docs.emdashcms.com/) for guides, API reference, and plugin development.
## Development
This is a pnpm monorepo. To contribute:
```bash
git clone https://github.com/emdash-cms/emdash.git && cd emdash
pnpm install
pnpm build
```
Run the demo (Node.js + SQLite, no Cloudflare account needed):
```bash
pnpm --filter emdash-demo seed
pnpm --filter emdash-demo dev
```
Open the admin at [http://localhost:4321/\_emdash/admin](http://localhost:4321/_emdash/admin).
```bash
pnpm test # run all tests
pnpm typecheck # type check
pnpm lint:quick # fast lint (< 1s)
pnpm format # format with oxfmt
```
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contributor guide.
## Repository Structure
```
packages/
core/ Astro integration, APIs, admin UI, CLI
auth/ Authentication library
blocks/ Portable Text block definitions
cloudflare/ Cloudflare adapter (D1, R2, Worker Loader)
plugins/ First-party plugins (forms, embeds, SEO, audit-log, etc.)
create-emdash/ npm create emdash scaffolding
gutenberg-to-portable-text/ WordPress block converter
templates/ Starter templates (blog, marketing, portfolio, starter, blank)
demos/ Development and example sites
docs/ Documentation site (Starlight)
```

182
TEMPLATES.md Normal file
View File

@@ -0,0 +1,182 @@
# EmDash Templates
Starter templates for building sites with EmDash CMS. Each template includes a seed file with demo content, so you can see how everything works right away.
## Available Templates
### Blog
A clean, minimal blog with posts, pages, categories, tags, and search.
**Features:**
- Featured post hero on homepage
- Post grid with reading time estimates
- Category and tag archives
- Full-text search
- RSS feed
- SEO metadata and JSON-LD
- Dark/light mode
**Pages:** Homepage, post archive, single post, single page, category archive, tag archive, search results, 404
### Marketing
A landing page template for products and services with modular content blocks.
**Features:**
- Hero, features, testimonials, pricing, and FAQ blocks
- Contact form with validation
- Portable Text content editing
- SEO metadata and JSON-LD
- Dark/light mode
**Pages:** Homepage, pricing, contact, 404
### Portfolio
A portfolio for showcasing creative work with project pages and tag filtering.
**Features:**
- Project grid with hover effects
- Tag-based filtering on work page
- Individual project pages with galleries
- Contact page
- RSS feed for new projects
- SEO metadata and JSON-LD
- Dark/light mode
**Pages:** Homepage, work listing, single project, about, contact, 404
## Using a Template
Each template has two variants:
- **Node.js** (`templates/blog`, `templates/marketing`, `templates/portfolio`) — uses SQLite and local file storage
- **Cloudflare** (`templates/blog-cloudflare`, etc.) — uses D1 and R2
### Quick Start
```bash
# Copy the template you want
cp -r templates/blog my-site
cd my-site
# Install dependencies
pnpm install
# Initialize the database and seed demo content
pnpm bootstrap
# Start the dev server
pnpm dev
```
Open http://localhost:4321 to see your site, and http://localhost:4321/\_emdash/admin for the CMS.
### Template Structure
```
templates/blog/
├── src/
│ ├── components/ # Astro components
│ ├── layouts/ # Page layouts
│ ├── pages/ # Route pages
│ ├── utils/ # Helper functions
│ └── live.config.ts # EmDash content loader
├── seed/
│ └── seed.json # Demo content
├── astro.config.mjs # Astro + EmDash config
├── package.json
└── tsconfig.json
```
## Contributing
### Cloudflare Variants
The cloudflare variants share most of their code with the base templates. Only these files differ:
- `astro.config.mjs` (cloudflare adapter, D1/R2 storage)
- `package.json` (different dependencies)
- `wrangler.jsonc` (cloudflare config)
Everything else is synced from the base template using a script:
```bash
./scripts/sync-cloudflare-templates.sh
```
**Run this after making changes** to `src/`, `seed/`, `tsconfig.json`, `emdash-env.d.ts`, or `.gitignore` in any base template. It copies those files to the corresponding cloudflare variant.
The primary Node demo is also synced from the blog template:
```bash
./scripts/sync-blog-demos.sh
```
This script does two kinds of sync:
- full template sync for `templates/blog` -> `demos/simple`
- frontend-only sync (keeping runtime-specific files) for:
- `templates/blog-cloudflare` -> `demos/cloudflare`
- `templates/blog-cloudflare` -> `demos/preview`
- `templates/blog` -> `demos/postgres`
### Taking Screenshots
Template screenshots live in `assets/templates/{template}/latest/` and are used in the README. To update them after making visual changes:
```bash
# Screenshot all templates (starts each dev server automatically)
pnpm screenshots
# Screenshot specific templates
pnpm screenshots blog
pnpm screenshots blog marketing
```
The script starts each template's dev server, captures screenshots, then stops the server before moving to the next template.
Page definitions are in `templates/screenshots.json`. Each page is captured at desktop (1440x900) and mobile (390x844) in both light and dark mode at 2x resolution. Screenshots are JPEG at 80% quality.
Output goes to `assets/templates/{template}/{datetime}/` and is copied to `assets/templates/{template}/latest/`. The dated directories are gitignored; only `latest/` is committed. The README references images from `latest/`.
Filenames follow the pattern `{page}-{mode}-{breakpoint}.jpg`, e.g. `homepage-light-desktop.jpg`, `post-dark-mobile.jpg`.
To add pages for a template, edit `templates/screenshots.json`.
### Adding a New Template
1. Create the base template in `templates/{name}/`
2. Include a seed file at `seed/seed.json` (or configure the path in `package.json` under `emdash.seed`)
3. Add the `typecheck` script to `package.json`
4. Create the cloudflare variant in `templates/{name}-cloudflare/` with the appropriate adapter config
5. Add the template pair to `scripts/sync-cloudflare-templates.sh`
6. Add the template's pages to `templates/screenshots.json` and run the screenshot script
7. Update the README template gallery
### Seed Files
Each template includes a seed file with demo content. The seed file format is documented in the CLI (`emdash seed --help`). Key points:
- Use `status: "published"` and include `published_at` for content that should appear on the site
- Reference media by URL — the seeder downloads and uploads images automatically
- Use the `taxonomies` object for categories and tags
- The `emdash.seed` field in `package.json` specifies the seed file location
### Testing Templates
Templates are covered by smoke tests that verify:
- Seed files parse correctly
- Seeds apply without errors
- The database passes doctor checks after seeding
Run the smoke tests:
```bash
pnpm --filter emdash exec vitest run --config vitest.smoke.config.ts
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

13
compose.yaml Normal file
View File

@@ -0,0 +1,13 @@
services:
emdash:
build: .
ports:
- "4321:4321"
volumes:
- emdash-data:/app/data
- emdash-uploads:/app/uploads
restart: unless-stopped
volumes:
emdash-data:
emdash-uploads:

View File

@@ -0,0 +1,21 @@
# @emdash-cms/demo-cloudflare
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
- @emdash-cms/cloudflare@0.0.3
- @emdash-cms/plugin-forms@0.0.3
- @emdash-cms/plugin-webhook-notifier@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2
- @emdash-cms/cloudflare@0.0.2
- @emdash-cms/plugin-forms@0.0.2
- @emdash-cms/plugin-webhook-notifier@0.0.2

View File

@@ -0,0 +1,63 @@
# EmDash Cloudflare Demo
This demo shows EmDash running on Cloudflare Workers with D1 database.
Uses Astro 6 + `@astrojs/cloudflare` v13 which runs the real `workerd` runtime in development.
## Setup
1. Create a D1 database:
```bash
pnpm db:create
```
2. Copy the database ID from the output and update `wrangler.jsonc`:
```jsonc
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash-demo",
"database_id": "YOUR_DATABASE_ID_HERE"
}
]
```
3. Start the dev server:
```bash
pnpm dev
```
EmDash runs migrations automatically on first request — no manual migration step needed.
4. Open http://localhost:4321/\_emdash/admin
## Preview
After building, you can preview with the real Workers runtime:
```bash
pnpm build
pnpm preview
```
## Deployment
```bash
pnpm deploy
```
This builds and deploys to Cloudflare Workers. EmDash handles migrations automatically on startup.
## Notes
- `astro dev` now uses `workerd` (the real Workers runtime) - development matches production
- `wrangler types` runs automatically before dev/build to generate TypeScript types for bindings
- No `platformProxy` config needed - Astro 6 handles this automatically
## TODO
- [ ] R2 storage for media uploads
- [ ] Auth integration (Cloudflare Access or custom)

View File

@@ -0,0 +1,116 @@
// @ts-check
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import {
d1,
r2,
access,
sandbox,
cloudflareCache,
cloudflareImages,
cloudflareStream,
} from "@emdash-cms/cloudflare";
import { formsPlugin } from "@emdash-cms/plugin-forms";
import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
import { defineConfig, fontProviders } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare({
imageService: "cloudflare",
}),
i18n: {
defaultLocale: "en",
locales: ["en", "fr", "es"],
fallback: {
fr: "en",
es: "en",
},
},
image: {
// Enable responsive images globally
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
// D1 database - binding name must match wrangler.jsonc
// session: "auto" enables read replicas (nearest replica for anon,
// bookmark-based consistency for authenticated users)
database: d1({ binding: "DB", session: "auto" }),
// R2 storage for media
storage: r2({ binding: "MEDIA" }),
// Cloudflare Access authentication
// Reads CF_ACCESS_AUDIENCE from env (wrangler secret or .dev.vars)
auth: access({
teamDomain: "cloudflare-cto.cloudflareaccess.com",
autoProvision: true,
defaultRole: 30, // Author
// Map your IdP groups to roles (optional)
// roleMapping: {
// "Admins": 50,
// "Editors": 40,
// },
}),
// Media providers - Cloudflare Images and Stream
// Reads from env vars at runtime: CF_ACCOUNT_ID, CF_IMAGES_TOKEN, CF_STREAM_TOKEN
// Or customize with accountIdEnvVar/apiTokenEnvVar options
mediaProviders: [
cloudflareImages({
accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID",
apiTokenEnvVar: "CF_MEDIA_API_TOKEN",
accountHash: "5LGXGUnHU18h6ehN_xjpXQ",
}),
cloudflareStream({
accountIdEnvVar: "CF_MEDIA_ACCOUNT_ID",
apiTokenEnvVar: "CF_MEDIA_API_TOKEN",
}),
],
// Trusted plugins (run in host worker)
plugins: [
// Test plugin that exercises all v2 APIs
formsPlugin(),
],
// Sandboxed plugins (run in isolated workers)
sandboxed: [webhookNotifierPlugin()],
// Sandbox runner for Cloudflare
sandboxRunner: sandbox(),
// Plugin marketplace
marketplace: "https://marketplace.emdashcms.com",
}),
],
experimental: {
cache: {
provider: cloudflareCache(),
},
routeRules: {
"/": {
maxAge: 3_600,
swr: 864_000,
},
"/[...slug]": {
maxAge: 3_600,
swr: 864_000,
},
},
},
fonts: [
{
provider: fontProviders.google(),
name: "Inter",
cssVariable: "--font-sans",
weights: [400, 500, 600, 700],
fallbacks: ["sans-serif"],
},
{
provider: fontProviders.google(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 500],
fallbacks: ["monospace"],
},
],
devToolbar: { enabled: false },
});

39
demos/cloudflare/emdash-env.d.ts vendored Normal file
View File

@@ -0,0 +1,39 @@
// Generated by EmDash on dev server start
// Do not edit manually
/// <reference types="emdash/locals" />
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
export interface Page {
id: string;
slug: string | null;
status: string;
title: string;
content?: PortableTextBlock[];
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
bylines?: ContentBylineCredit[];
}
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;
bylines?: ContentBylineCredit[];
}
declare module "emdash" {
interface EmDashCollections {
pages: Page;
posts: Post;
}
}

View File

@@ -0,0 +1,40 @@
{
"name": "@emdash-cms/demo-cloudflare",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build:all": "pnpm run --filter @emdash-cms/demo-cloudflare... build",
"preview": "astro preview",
"deploy": "pnpm build:all && wrangler deploy",
"db:create": "wrangler d1 create emdash-demo",
"db:reset:remote": "./scripts/reset-db.sh",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/cloudflare": "workspace:*",
"@emdash-cms/plugin-forms": "workspace:*",
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@cloudflare/workers-types": "catalog:",
"@types/node": "catalog:",
"wrangler": "catalog:"
},
"emdash": {
"seed": "seed/seed.json"
},
"peerDependencies": {},
"optionalDependencies": {}
}

View File

@@ -0,0 +1,13 @@
{
"id": "sandbox-test",
"version": "1.0.0",
"capabilities": ["read:content"],
"allowedHosts": [],
"storage": {
"logs": {
"indexes": ["timestamp"]
}
},
"hooks": ["content:afterSave"],
"routes": ["test", "logs", "restriction-test"]
}

View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Reset remote D1 database by deleting and recreating it.
# With Access auth + autoProvision, users are recreated on first login.
#
# Usage: pnpm db:reset:remote
set -euo pipefail
DB_NAME="emdash-demo"
WRANGLER_CONFIG="wrangler.jsonc"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR/.."
echo "Deleting database '$DB_NAME'..."
npx wrangler d1 delete "$DB_NAME" --skip-confirmation
echo "Creating new database '$DB_NAME'..."
OUTPUT=$(npx wrangler d1 create "$DB_NAME" 2>&1)
echo "$OUTPUT"
# Extract new database ID from output
NEW_ID=$(echo "$OUTPUT" | grep -o '"database_id": "[^"]*"' | head -1 | cut -d'"' -f4)
if [ -z "$NEW_ID" ]; then
echo "Failed to extract new database ID"
exit 1
fi
echo "New database ID: $NEW_ID"
# Update wrangler.jsonc with new ID
if [ -f "$WRANGLER_CONFIG" ]; then
echo "Updating $WRANGLER_CONFIG with new database ID..."
sed -i '' "s/\"database_id\": \"[^\"]*\"/\"database_id\": \"$NEW_ID\"/" "$WRANGLER_CONFIG"
fi
echo ""
echo "Database recreated. Next steps:"
echo " 1. pnpm deploy (redeploy with new DB ID)"
echo " 2. Visit /_emdash/admin to run setup wizard (applies seed content)"
echo " 3. Access will auto-provision your admin user on first login"

View File

@@ -0,0 +1,53 @@
-- Reset content and schema, preserving users and auth.
-- After running this, redeploy and go through the setup wizard to re-seed.
--
-- Usage: npx wrangler d1 execute emdash-demo --remote --file=scripts/reset-db.sql
--
-- NOTE: D1 may not support IF EXISTS reliably. If a table doesn't exist,
-- the statement fails and D1 aborts. Use reset-db.sh instead, which
-- discovers existing tables dynamically.
-- Drop dynamic content tables
DROP TABLE IF EXISTS ec_posts;
DROP TABLE IF EXISTS ec_pages;
-- Drop FTS virtual tables
DROP TABLE IF EXISTS ec_posts_fts;
DROP TABLE IF EXISTS ec_pages_fts;
-- Drop emdash system tables (child tables before parents)
DROP TABLE IF EXISTS _emdash_entry_taxonomies;
DROP TABLE IF EXISTS _emdash_entries;
DROP TABLE IF EXISTS _emdash_revisions;
DROP TABLE IF EXISTS _emdash_seo;
DROP TABLE IF EXISTS _emdash_comments;
DROP TABLE IF EXISTS _emdash_fields;
DROP TABLE IF EXISTS _emdash_collections;
DROP TABLE IF EXISTS _emdash_taxonomy_terms;
DROP TABLE IF EXISTS _emdash_taxonomies;
DROP TABLE IF EXISTS _emdash_media;
DROP TABLE IF EXISTS _emdash_menu_items;
DROP TABLE IF EXISTS _emdash_menus;
DROP TABLE IF EXISTS _emdash_widgets;
DROP TABLE IF EXISTS _emdash_widget_areas;
DROP TABLE IF EXISTS _emdash_sections;
DROP TABLE IF EXISTS _emdash_redirects;
DROP TABLE IF EXISTS _emdash_404_log;
DROP TABLE IF EXISTS _emdash_plugins;
DROP TABLE IF EXISTS _emdash_cron_tasks;
DROP TABLE IF EXISTS _emdash_authorization_codes;
DROP TABLE IF EXISTS _emdash_oauth_tokens;
DROP TABLE IF EXISTS _emdash_device_codes;
DROP TABLE IF EXISTS _emdash_api_tokens;
DROP TABLE IF EXISTS _emdash_oauth_clients;
-- Clear options (setup flag etc.) so the setup wizard re-runs
DROP TABLE IF EXISTS options;
-- Drop migration tracking so migrations re-run
DROP TABLE IF EXISTS _emdash_migrations;
DROP TABLE IF EXISTS _emdash_migrations_lock;
DROP TABLE IF EXISTS d1_migrations;
-- Auth tables are intentionally preserved:
-- users, passkeys, sessions, login_tokens, invites, oauth_accounts

View File

@@ -0,0 +1,778 @@
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Blog Starter",
"description": "A blog with posts and pages",
"author": "EmDash"
},
"settings": {
"title": "My Blog",
"tagline": "Thoughts on building for the web"
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "search", "seo"],
"commentsEnabled": true,
"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"
}
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"supports": ["drafts", "revisions", "search"],
"fields": [
{
"slug": "title",
"label": "Title",
"type": "string",
"required": true,
"searchable": true
},
{
"slug": "content",
"label": "Content",
"type": "portableText",
"searchable": true
}
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "development", "label": "Development" },
{ "slug": "design", "label": "Design" },
{ "slug": "notes", "label": "Notes" }
]
},
{
"name": "tag",
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["posts"],
"terms": [
{ "slug": "webdev", "label": "Web Development" },
{ "slug": "opinion", "label": "Opinion" },
{ "slug": "tools", "label": "Tools" },
{ "slug": "creativity", "label": "Creativity" }
]
}
],
"bylines": [
{
"id": "byline-editorial",
"slug": "emdash-editorial",
"displayName": "EmDash Editorial"
},
{
"id": "byline-guest",
"slug": "guest-contributor",
"displayName": "Guest Contributor",
"isGuest": true
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "About", "url": "/pages/about" },
{ "type": "custom", "label": "Posts", "url": "/posts" }
]
}
],
"widgetAreas": [
{
"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
}
}
]
},
{
"name": "footer",
"label": "Footer",
"description": "Widget area displayed in the site footer",
"widgets": [
{
"type": "content",
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A blog about software, design, and the occasional stray thought."
}
]
}
]
}
]
}
],
"sections": [
{
"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. No spam, unsubscribe anytime."
}
]
}
]
},
{
"slug": "about-author",
"title": "About the Author",
"description": "Brief author bio for use in posts or pages",
"keywords": ["author", "bio", "about"],
"source": "theme",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet."
}
]
}
]
}
],
"content": {
"pages": [
{
"id": "about",
"slug": "about",
"status": "published",
"data": {
"title": "About",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Built with Astro and EmDash. The source is open if you want to see how it works."
}
]
}
]
}
}
],
"posts": [
{
"id": "post-1",
"slug": "building-for-the-long-term",
"status": "published",
"data": {
"title": "Building for the Long Term",
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
"alt": "Code on a monitor in a dark room",
"filename": "building-long-term.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "What survives" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time."
}
]
}
]
},
"bylines": [
{ "byline": "byline-editorial" },
{ "byline": "byline-guest", "roleLabel": "Guest essay" }
],
"taxonomies": {
"category": ["development"],
"tag": ["opinion"]
}
},
{
"id": "post-2",
"slug": "the-case-for-static",
"status": "published",
"data": {
"title": "The Case for Static",
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
"alt": "Laptop and coffee on a wooden table",
"filename": "case-for-static.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "The performance argument" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times."
}
]
}
]
},
"bylines": [{ "byline": "byline-editorial" }],
"taxonomies": {
"category": ["development"],
"tag": ["webdev", "opinion"]
}
},
{
"id": "post-3",
"slug": "learning-in-public",
"status": "published",
"data": {
"title": "Learning in Public",
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
"alt": "Notebook and pen on a desk",
"filename": "learning-in-public.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "The fear of being wrong" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize."
}
]
}
]
},
"taxonomies": {
"category": ["notes"],
"tag": ["opinion"]
}
},
{
"id": "post-4",
"slug": "small-tools-big-impact",
"status": "published",
"data": {
"title": "Small Tools, Big Impact",
"excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
"alt": "Wrenches and hand tools hanging on a workshop wall",
"filename": "small-tools.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "The Unix philosophy, revisited" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable."
}
]
}
]
},
"taxonomies": {
"category": ["development"],
"tag": ["tools"]
}
},
{
"id": "post-5",
"slug": "designing-with-constraints",
"status": "published",
"data": {
"title": "Designing with Constraints",
"excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
"alt": "Pencils and design tools on a desk",
"filename": "designing-with-constraints.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "Embracing the box" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies."
}
]
}
]
},
"taxonomies": {
"category": ["design"],
"tag": ["creativity"]
}
},
{
"id": "post-6",
"slug": "a-weekend-with-a-side-project",
"status": "published",
"data": {
"title": "A Weekend with a Side Project",
"excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
"alt": "Code on a screen with a dark theme",
"filename": "weekend-side-project.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?"
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "Why side projects matter" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend."
}
]
}
]
},
"taxonomies": {
"category": ["development"],
"tag": ["creativity"]
}
},
{
"id": "post-7",
"slug": "notes-on-simplicity",
"status": "published",
"data": {
"title": "Notes on Simplicity",
"excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
"alt": "Geometric pattern carved into white paper",
"filename": "notes-on-simplicity.jpg"
}
},
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated."
}
]
},
{
"_type": "block",
"style": "h2",
"children": [{ "_type": "span", "text": "Removing as a feature" }]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface."
}
]
},
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect."
}
]
}
]
},
"taxonomies": {
"category": ["notes"],
"tag": ["opinion"]
}
},
{
"id": "post-draft",
"slug": "work-in-progress",
"status": "draft",
"data": {
"title": "Work in Progress",
"excerpt": "This post is still being written.",
"content": [
{
"_type": "block",
"style": "normal",
"children": [
{
"_type": "span",
"text": "This is a draft post that won't appear in the public listing."
}
]
}
]
}
}
]
}
}

View File

@@ -0,0 +1,279 @@
---
import type { MediaValue, ContentBylineCredit } from "emdash";
import { Image } from "emdash/ui";
interface Props {
title: string;
excerpt?: string;
featuredImage?: MediaValue | string;
href: string;
date?: Date;
readingTime?: number;
tags?: Array<{ slug: string; label: string }>;
bylines?: ContentBylineCredit[];
}
const {
title,
excerpt,
featuredImage,
href,
date,
readingTime,
tags,
bylines,
} = Astro.props;
const formattedDate = date
? date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
---
<article class="post-card">
<a href={href} class="card-link">
{
featuredImage ? (
<div class="card-image">
<Image image={featuredImage} />
</div>
) : (
<div class="card-placeholder" />
)
}
<div class="card-body">
<div class="card-meta">
{
bylines && bylines.length > 0 && (
<>
<div class="card-bylines">
{bylines.slice(0, 1).map((credit) => (
<span class="card-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="card-byline-avatar"
/>
)}
<span class="card-byline-name">
{credit.byline.displayName}
</span>
</span>
))}
{bylines.length > 1 && (
<span
class="byline-more"
data-tooltip={bylines
.slice(1)
.map((c) => c.byline.displayName)
.join(", ")}
title={bylines
.slice(1)
.map((c) => c.byline.displayName)
.join(", ")}
tabindex="0"
>
+{bylines.length - 1}
</span>
)}
</div>
{(formattedDate || readingTime) && <span class="meta-dot" />}
</>
)
}
{formattedDate && <time>{formattedDate}</time>}
{formattedDate && readingTime && <span class="meta-dot" />}
{readingTime && <span>{readingTime} min</span>}
</div>
<h2 class="card-title">{title}</h2>
{excerpt && <p class="card-excerpt">{excerpt}</p>}
</div>
</a>
{
tags && tags.length > 0 && (
<div class="card-tags">
{tags.slice(0, 2).map((tag) => (
<a href={`/tag/${tag.slug}`} class="card-tag">
{tag.label}
</a>
))}
</div>
)
}
</article>
<style>
.post-card {
display: flex;
flex-direction: column;
}
.card-link {
display: block;
text-decoration: none;
color: inherit;
}
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.card-link:hover .card-image img {
transform: scale(1.03);
}
.card-placeholder {
aspect-ratio: 16 / 10;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-body {
flex: 1;
}
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: 0;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.card-meta time,
.card-meta span:not(.meta-dot) {
white-space: nowrap;
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.card-link:hover .card-title {
color: var(--color-accent);
}
.card-excerpt {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.card-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.card-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.card-bylines {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.card-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.card-byline-avatar {
width: var(--avatar-size-xs);
height: var(--avatar-size-xs);
border-radius: 50%;
object-fit: cover;
}
.card-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-more {
position: relative;
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
cursor: default;
border-radius: var(--radius);
outline-offset: 2px;
}
.byline-more:focus-visible {
outline: 2px solid var(--color-accent);
}
.byline-more[data-tooltip]:hover::after,
.byline-more[data-tooltip]:focus-visible::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
background: var(--color-text);
color: var(--color-bg);
font-size: var(--font-size-xs);
font-weight: 400;
padding: var(--spacing-1) var(--spacing-2);
border-radius: var(--radius);
pointer-events: none;
z-index: 10;
}
</style>

View File

@@ -0,0 +1,45 @@
---
interface Props {
tags: Array<{ slug: string; label: string }>;
class?: string;
}
const { tags, class: className } = Astro.props;
---
{tags.length > 0 && (
<ul class:list={["tag-list", className]}>
{tags.map((tag) => (
<li>
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
</li>
))}
</ul>
)}
<style>
.tag-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
list-style: none;
padding: 0;
margin: 0;
}
.tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition: color var(--transition-fast), background var(--transition-fast);
}
.tag:hover {
color: var(--color-text);
background: var(--color-border);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -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() }),
};

View File

@@ -0,0 +1,33 @@
---
import Base from "../layouts/Base.astro";
---
<Base title="Page not found">
<div class="not-found">
<h1>404</h1>
<p>The page you're looking for doesn't exist.</p>
<a href="/">Go back home</a>
</div>
</Base>
<style>
.not-found {
text-align: center;
padding: var(--spacing-24) var(--spacing-6);
}
.not-found h1 {
font-size: var(--font-size-5xl);
margin-bottom: var(--spacing-2);
color: var(--color-border);
}
.not-found p {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.not-found a {
color: var(--color-text);
}
</style>

View File

@@ -0,0 +1,31 @@
---
/**
* ALS request context test — validates AsyncLocalStorage propagation
* from EmDash middleware through Astro's render pipeline on workerd.
*
* Test:
* curl http://localhost:4321/als-test
* → hasContext: false (fast path, no ALS)
*
* curl -b "emdash-edit-mode=true" http://localhost:4321/als-test
* → hasContext: true, editMode: false (no auth)
*
* Remove this page once validated.
*/
import { getRequestContext } from "emdash/request-context";
const ctx = getRequestContext();
---
<html>
<head><title>ALS Test (Cloudflare)</title></head>
<body>
<h1>ALS Request Context Test</h1>
<pre
id="result">{JSON.stringify({
hasContext: ctx !== undefined,
editMode: ctx?.editMode ?? false,
preview: ctx?.preview ?? null,
}, null, 2)}</pre>
</body>
</html>

View File

@@ -0,0 +1,129 @@
---
import {
getTerm,
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 term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
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) ?? [],
}));
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,463 @@
---
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);
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",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>

View File

@@ -0,0 +1,108 @@
---
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
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");
}
Astro.cache.set(cacheHint);
---
<Base
title={page.data.title}
content={{ collection: "pages", id: page.data.id, slug }}
>
<article class="page-article">
<header class="page-header">
<h1 class="page-title" {...page.edit.title}>{page.data.title}</h1>
</header>
<div class="page-content">
<PortableText value={page.data.content} />
</div>
</article>
</Base>
<style>
.page-article {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-8);
}
.page-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
}
.page-content :global(p) {
margin-bottom: 1.5em;
}
.page-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2em;
margin-bottom: 0.75em;
}
.page-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 1.75em;
margin-bottom: 0.5em;
}
.page-content :global(blockquote) {
margin: 1.5em 0;
padding-left: var(--spacing-6);
border-left: 3px solid var(--color-border);
color: var(--color-muted);
}
.page-content :global(pre) {
margin: 1.5em 0;
padding: var(--spacing-4);
background: var(--color-surface);
border-radius: var(--radius);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
}
.page-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.15em 0.3em;
border-radius: var(--radius);
}
.page-content :global(pre code) {
background: none;
padding: 0;
}
.page-content :global(ul),
.page-content :global(ol) {
margin-bottom: 1.5em;
padding-left: var(--spacing-5);
}
.page-content :global(li) {
margin-bottom: 0.5em;
}
</style>

View File

@@ -0,0 +1,970 @@
---
import {
getEmDashEntry,
getEmDashCollection,
getEntryTerms,
getTermsForEntries,
getSeoMeta,
decodeSlug,
getSiteSettings,
} from "emdash";
import {
Image,
PortableText,
Comments,
CommentForm,
WidgetArea,
} 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";
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");
}
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<string, unknown>;
// 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<string, unknown> | 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: `/posts/${slug}`,
defaultOgImage: featuredImageUrl,
});
// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];
// Get reading time
const readingTime = getReadingTime(post.data.content);
// Fetch this post's tags and the related-posts list in parallel — they're
// independent queries, so running them concurrently halves the round-trip
// cost on remote databases.
// Note: post.id is the slug, post.data.id is the database ULID.
const [tags, { entries: recentPosts }] = await Promise.all([
getEntryTerms("posts", post.data.id, "tag"),
// Fetch a few extra in case the current post is among them
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 4,
}),
]);
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;
---
<Base
title={seo.title}
pageTitle={seo.ogTitle}
description={seo.description}
image={seo.ogImage}
canonical={seo.canonical}
robots={seo.robots}
type="article"
publishedTime={post.data.publishedAt?.toISOString() ?? null}
modifiedTime={post.data.updatedAt.toISOString()}
content={{ collection: "posts", id: post.data.id, slug }}
>
<article class="article">
{/* Hero: Full-width featured image */}
{
post.data.featured_image && (
<div class="article-hero" {...post.edit.featured_image}>
<Image image={post.data.featured_image} />
</div>
)
}
{/* Three-column layout */}
<div class="article-grid">
{/* Left gutter: Meta information */}
<aside class="article-meta-col">
<div class="meta-sticky">
{
bylines.length > 0 && (
<div class="meta-block byline-block">
<span class="meta-label">
{bylines.length === 1 ? "Author" : "Authors"}
</span>
<div class="bylines">
{bylines.map((credit) => (
<div class="byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="byline-avatar"
/>
)}
<div class="byline-info">
<span class="byline-name">
{credit.byline.displayName}
</span>
{credit.roleLabel && (
<span class="byline-role">{credit.roleLabel}</span>
)}
</div>
</div>
))}
</div>
</div>
)
}
{
publishDate && (
<div class="meta-block">
<span class="meta-label">Published</span>
<time class="meta-value">{publishDate}</time>
</div>
)
}
<div class="meta-block">
<span class="meta-label">Reading time</span>
<span class="meta-value">{readingTime} min</span>
</div>
{
tags.length > 0 && (
<div class="meta-block">
<span class="meta-label">Tags</span>
<div class="meta-tags">
{tags.map((t) => (
<a href={`/tag/${t.slug}`} class="meta-tag">
{t.label}
</a>
))}
</div>
</div>
)
}
</div>
</aside>
{/* Main content */}
<div class="article-main">
<header class="article-header">
<div class="article-meta">
{
bylines.length > 0 && (
<>
<span class="article-meta-byline">
{bylines.map((credit, i) => (
<>
{i > 0 && ", "}
{credit.byline.displayName}
</>
))}
</span>
<span class="meta-dot" />
</>
)
}
{
publishDate && (
<>
<time>{publishDate}</time>
<span class="meta-dot" />
</>
)
}
<span>{readingTime} min read</span>
</div>
<h1 class="article-title" {...post.edit.title}>{post.data.title}</h1>
{
post.data.excerpt && (
<p class="article-excerpt" {...post.edit.excerpt}>{post.data.excerpt}</p>
)
}
</header>
<div class="article-content">
<PortableText value={post.data.content} />
</div>
<div class="article-comments">
<Comments collection="posts" contentId={post.data.id} threaded />
<CommentForm collection="posts" contentId={post.data.id} />
</div>
</div>
{/* Right gutter: TOC + Sidebar widgets */}
<aside class="article-sidebar">
<div class="sidebar-sticky">
<nav class="toc" aria-label="Table of contents">
<h4 class="toc-title">On this page</h4>
<div class="toc-content" id="toc-content">
<!-- Populated by JS -->
</div>
</nav>
<div class="sidebar-widgets">
<WidgetArea name="sidebar" />
</div>
</div>
</aside>
</div>
</article>
{
otherPostsWithTags.length > 0 && (
<section class="more-posts">
<div class="more-inner">
<h2 class="more-title">Continue reading</h2>
<div class="more-grid">
{otherPostsWithTags.map(
({ post: p, tags: postTags, bylines: postBylines }) => (
<PostCard
title={p.data.title}
excerpt={p.data.excerpt}
featuredImage={p.data.featured_image}
href={`/posts/${p.id}`}
date={p.data.publishedAt ?? undefined}
readingTime={getReadingTime(p.data.content)}
tags={postTags.map((t) => ({ slug: t.slug, label: t.label }))}
bylines={postBylines}
/>
)
)}
</div>
</div>
</section>
)
}
<script>
// Build table of contents from h2/h3 headings
function buildToc() {
const content = document.querySelector(".article-content");
const tocContainer = document.getElementById("toc-content");
if (!content || !tocContainer) return;
const headings = content.querySelectorAll("h2, h3");
if (headings.length === 0) {
// Hide TOC if no headings
const toc = document.querySelector(".toc") as HTMLElement | null;
if (toc) toc.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "toc-list";
headings.forEach((heading, index) => {
// Add ID if missing
if (!heading.id) {
heading.id = `heading-${index}`;
}
const li = document.createElement("li");
li.className =
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "toc-link";
link.textContent = heading.textContent;
li.appendChild(link);
list.appendChild(li);
});
tocContainer.appendChild(list);
// Highlight current section on scroll
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
const link = tocContainer.querySelector(`a[href="#${id}"]`);
if (link) {
if (entry.isIntersecting) {
tocContainer
.querySelectorAll(".toc-link")
.forEach((l) => l.classList.remove("active"));
link.classList.add("active");
}
}
});
},
{ rootMargin: "-80px 0px -80% 0px" }
);
headings.forEach((heading) => observer.observe(heading));
}
buildToc();
</script>
</Base>
<style>
/* Article container */
.article {
max-width: var(--wide-width);
margin: 0 auto;
}
/* Hero image - full width within container */
.article-hero {
margin: var(--spacing-16) var(--spacing-6);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.article-hero img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
}
/* Three-column grid */
.article-grid {
display: grid;
grid-template-columns:
var(--meta-col-width) minmax(0, var(--content-width))
var(--gutter-width);
gap: var(--spacing-10);
justify-content: center;
padding: 0 var(--spacing-6);
margin: var(--spacing-16) 0;
}
/* Left column: Meta */
.article-meta-col {
display: block;
}
.meta-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.meta-block {
margin-bottom: var(--spacing-6);
}
.meta-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-1);
}
.meta-value {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.meta-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.meta-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.bylines {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.byline {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.byline-avatar {
width: var(--avatar-size-lg);
height: var(--avatar-size-lg);
border-radius: 50%;
object-fit: cover;
}
.byline-info {
display: flex;
flex-direction: column;
}
.byline-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.byline-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
/* Main content column */
.article-main {
min-width: 0;
}
.article-header {
margin-bottom: var(--spacing-10);
}
.article-header .article-meta {
display: none;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.article-meta-byline {
font-weight: 500;
color: var(--color-text-secondary);
}
.article-title {
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-4);
}
.article-excerpt {
font-size: var(--font-size-xl);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* Article content typography */
.article-content {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
}
.article-content :global(p) {
margin-bottom: 1.5em;
}
.article-content :global(h2) {
font-size: var(--font-size-2xl);
margin-top: 2.5em;
margin-bottom: 0.75em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(h3) {
font-size: var(--font-size-xl);
margin-top: 2em;
margin-bottom: 0.5em;
scroll-margin-top: calc(var(--nav-height) + var(--spacing-4));
}
.article-content :global(blockquote) {
margin: 2em 0;
padding: var(--spacing-4) var(--spacing-6);
border-left: 3px solid var(--color-border);
background: var(--color-bg-subtle);
border-radius: 0 var(--radius) var(--radius) 0;
color: var(--color-text-secondary);
font-style: italic;
}
.article-content :global(pre) {
margin: 2em 0;
padding: var(--spacing-5);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
overflow-x: auto;
font-family: var(--font-mono);
font-size: var(--font-size-sm);
line-height: 1.6;
}
.article-content :global(code) {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--color-surface);
padding: 0.2em 0.4em;
border-radius: var(--radius);
}
.article-content :global(pre code) {
background: none;
padding: 0;
}
.article-content :global(ul),
.article-content :global(ol) {
margin-bottom: 1.5em;
padding-left: 1.5em;
}
.article-content :global(li) {
margin-bottom: 0.5em;
}
.article-content :global(img) {
margin: 2em 0;
border-radius: var(--radius-lg);
}
.article-content :global(hr) {
margin: 3em 0;
border: none;
border-top: 1px solid var(--color-border);
}
.article-content :global(a) {
color: var(--color-accent);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}
.article-content :global(a:hover) {
text-decoration-thickness: 2px;
}
/* Right column: TOC + Sidebar */
.article-sidebar {
display: block;
}
.sidebar-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.toc {
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-6);
border-bottom: 1px solid var(--color-border-subtle);
}
.toc-title {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.toc-content :global(.toc-list) {
list-style: none;
padding: 0;
margin: 0;
}
.toc-content :global(.toc-item) {
margin-bottom: var(--spacing-1);
}
.toc-content :global(.toc-item--nested) {
padding-left: var(--spacing-3);
}
.toc-content :global(.toc-link) {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
padding: var(--spacing-1) 0;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.toc-content :global(.toc-link:hover),
.toc-content :global(.toc-link.active) {
color: var(--color-text);
}
/* Sidebar widgets */
.sidebar-widgets :global(.widget-area) {
display: flex;
flex-direction: column;
gap: var(--spacing-6);
}
.sidebar-widgets :global(.widget) {
font-size: var(--font-size-sm);
}
.sidebar-widgets :global(.widget__title) {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-3);
}
.sidebar-widgets :global(.widget__content) {
color: var(--color-text-secondary);
line-height: var(--leading-relaxed);
}
/* Sidebar search widget */
.sidebar-widgets :global(.widget-search) {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-search__input) {
width: 100%;
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-sans);
font-size: var(--font-size-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast);
}
.sidebar-widgets :global(.widget-search__input)::placeholder {
color: var(--color-muted);
}
.sidebar-widgets :global(.widget-search__input):focus,
.sidebar-widgets :global(.widget-search__input):focus-visible {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
.sidebar-widgets :global(.widget-search__button) {
display: none;
}
/* Sidebar categories widget */
.sidebar-widgets :global(.widget-categories) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-categories li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-categories li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-categories__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-categories__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-categories__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Sidebar tags widget - pill style */
.sidebar-widgets :global(.widget-tags__cloud) {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-tags__cloud li) {
margin: 0;
}
.sidebar-widgets :global(.widget-tags__link) {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-widgets :global(.widget-tags__link:hover) {
color: var(--color-text);
background: var(--color-border);
}
.sidebar-widgets :global(.widget-tags__count) {
display: none;
}
/* Sidebar recent posts widget */
.sidebar-widgets :global(.widget-recent-posts) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-recent-posts li) {
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-recent-posts a) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.sidebar-widgets :global(.widget-recent-posts a:hover) {
color: var(--color-text);
}
/* Sidebar archives widget */
.sidebar-widgets :global(.widget-archives) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-archives li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-archives li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-archives__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-archives__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-archives__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Comments section */
.article-comments {
margin-top: var(--spacing-16);
padding-top: var(--spacing-10);
border-top: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments) {
--ec-comment-border: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments-heading) {
font-size: var(--font-size-2xl);
font-weight: 600;
margin-bottom: var(--spacing-8);
}
.article-comments :global(.ec-comment-author) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-date) {
font-family: var(--font-mono);
color: var(--color-muted);
}
.article-comments :global(.ec-comment-body) {
color: var(--color-text);
}
.article-comments :global(.ec-comment-form-field input),
.article-comments :global(.ec-comment-form-field textarea) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
color: var(--color-text) !important;
}
.article-comments :global(.ec-comment-user-info) {
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.article-comments :global(.ec-comment-form-submit) {
background: var(--color-accent) !important;
color: var(--color-on-accent) !important;
}
/* More posts section */
.more-posts {
background: var(--color-bg-subtle);
padding: var(--spacing-16) 0;
margin-top: var(--spacing-16);
}
.more-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: 0 var(--spacing-6);
}
.more-title {
font-size: var(--font-size-2xl);
font-weight: 600;
margin-bottom: var(--spacing-10);
}
.more-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
}
/* Responsive */
@media (max-width: 1100px) {
.article-grid {
grid-template-columns: minmax(0, var(--content-width));
gap: 0;
}
.article-meta-col,
.article-sidebar {
display: none;
}
.article-header .article-meta {
display: flex;
}
}
@media (max-width: 900px) {
.article-hero {
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
border-radius: var(--radius);
}
.article-grid {
padding: 0 var(--spacing-4);
}
.more-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.article-title {
font-size: var(--font-size-3xl);
}
.more-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,272 @@
---
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 { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
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",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title">{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-accent);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>

View File

@@ -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?.toString() || 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 = `${siteUrl}/posts/${post.id}`;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteTagline)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] 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;
}

View File

@@ -0,0 +1,481 @@
---
/**
* Sandbox Plugin Test Page
*
* Tests the full sandbox architecture:
* 1. PluginBridge WorkerEntrypoint provides controlled DB access
* 2. Sandbox gets a SERVICE BINDING to the bridge (not direct DB access)
* 3. Bridge validates capabilities and scopes operations
*/
interface TestResult {
step: string;
success: boolean;
data?: unknown;
error?: string;
}
const results: TestResult[] = [];
// Get Cloudflare context
const cfContext = (Astro.locals as unknown as Record<string, unknown>).cfContext;
// @ts-ignore - env typing
const env = (await import("cloudflare:workers")).env;
// @ts-ignore
const loader = env.LOADER;
if (!loader) {
results.push({ step: "Check LOADER binding", success: false, error: "LOADER not available" });
} else {
results.push({ step: "Check LOADER binding", success: true });
}
if (!cfContext) {
results.push({ step: "Check cfContext", success: false, error: "cfContext not available" });
} else {
results.push({ step: "Check cfContext", success: true });
}
// Check for ctx.exports (requires enable_ctx_exports compatibility flag)
// @ts-ignore
const exports = cfContext?.exports;
if (!exports) {
results.push({ step: "Check ctx.exports", success: false, error: "ctx.exports not available - need enable_ctx_exports flag" });
} else {
results.push({ step: "Check ctx.exports", success: true });
}
// Check for PluginBridge export
// @ts-ignore
const PluginBridge = exports?.PluginBridge;
if (!PluginBridge) {
results.push({ step: "Check PluginBridge export", success: false, error: "PluginBridge not in ctx.exports" });
} else {
results.push({ step: "Check PluginBridge export", success: true });
}
// Test the bridge directly (without sandbox first)
if (PluginBridge) {
try {
// Create a bridge instance with props
const bridge = PluginBridge({
props: {
pluginId: "test-plugin",
pluginVersion: "1.0.0",
capabilities: ["read:content"],
allowedHosts: [],
storageCollections: ["logs"],
}
});
results.push({ step: "Create bridge instance", success: true });
// Test KV operations
try {
await bridge.kvSet("test-key", { hello: "world" });
const value = await bridge.kvGet("test-key");
await bridge.kvDelete("test-key");
results.push({
step: "Bridge KV operations",
success: value?.hello === "world",
data: { stored: { hello: "world" }, retrieved: value }
});
} catch (e) {
results.push({ step: "Bridge KV operations", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test storage operations
try {
await bridge.storagePut("logs", "test-id", { message: "test log" });
const value = await bridge.storageGet("logs", "test-id");
await bridge.storageDelete("logs", "test-id");
results.push({
step: "Bridge storage operations",
success: value?.message === "test log",
data: value
});
} catch (e) {
results.push({ step: "Bridge storage operations", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test undeclared storage (should fail)
try {
await bridge.storageGet("undeclared", "test");
results.push({ step: "Block undeclared storage", success: false, error: "Should have thrown" });
} catch (e) {
results.push({
step: "Block undeclared storage",
success: true,
data: { blocked: true, error: e instanceof Error ? e.message : String(e) }
});
}
// Test network without capability (should fail)
try {
await bridge.httpFetch("https://example.com");
results.push({ step: "Block network without capability", success: false, error: "Should have thrown" });
} catch (e) {
results.push({
step: "Block network without capability",
success: true,
data: { blocked: true, error: e instanceof Error ? e.message : String(e) }
});
}
} catch (e) {
results.push({ step: "Create bridge instance", success: false, error: e instanceof Error ? e.message : String(e) });
}
}
// Now test the full sandbox with Worker Loader
if (loader && PluginBridge) {
try {
// Create a bridge binding for this specific plugin
const bridgeBinding = PluginBridge({
props: {
pluginId: "sandbox-test",
pluginVersion: "1.0.0",
capabilities: ["read:content"],
allowedHosts: [],
storageCollections: ["logs"],
}
});
// Sandbox code that uses the bridge
const sandboxCode = `
import { WorkerEntrypoint } from "cloudflare:workers";
export default class PluginEntrypoint extends WorkerEntrypoint {
async test() {
return {
success: true,
message: "Hello from sandbox!",
pluginId: this.env.PLUGIN_ID,
};
}
async testKv() {
const bridge = this.env.BRIDGE;
await bridge.kvSet("sandbox-test", { from: "sandbox" });
const value = await bridge.kvGet("sandbox-test");
await bridge.kvDelete("sandbox-test");
return { success: true, value };
}
async testStorage() {
const bridge = this.env.BRIDGE;
await bridge.storagePut("logs", "sandbox-log", { ts: Date.now() });
const value = await bridge.storageGet("logs", "sandbox-log");
await bridge.storageDelete("logs", "sandbox-log");
return { success: true, value };
}
async testBlockedStorage() {
const bridge = this.env.BRIDGE;
try {
await bridge.storageGet("undeclared", "test");
return { success: false, error: "Should have been blocked" };
} catch (e) {
return { success: true, blocked: true, error: e.message };
}
}
async testBlockedNetwork() {
const bridge = this.env.BRIDGE;
try {
await bridge.httpFetch("https://example.com");
return { success: false, error: "Should have been blocked" };
} catch (e) {
return { success: true, blocked: true, error: e.message };
}
}
// ISOLATION TESTS - verify sandbox can't bypass bridge
async testDirectFetchBlocked() {
// Sandbox has globalOutbound: null, so fetch should fail
try {
const resp = await fetch("https://example.com");
return { success: false, error: "Direct fetch should be blocked but got: " + resp.status };
} catch (e) {
return { success: true, blocked: true, error: e.message };
}
}
async testNoDbBinding() {
// Sandbox should NOT have DB binding - only BRIDGE
const hasDb = !!this.env.DB;
const hasMedia = !!this.env.MEDIA;
const bindings = Object.keys(this.env);
return {
success: !hasDb && !hasMedia,
hasDb,
hasMedia,
bindings,
error: hasDb || hasMedia ? "Sandbox should not have direct DB/MEDIA bindings" : null
};
}
async testNoGlobals() {
// Check that dangerous globals are not available
const checks = {
hasGlobalFetch: typeof globalThis.fetch === "function",
// After globalOutbound: null, fetch exists but should fail
};
return { success: true, checks };
}
}
`;
// Spawn the sandbox with bridge binding
const worker = loader.get("sandbox-full-test-" + Date.now(), () => ({
compatibilityDate: "2025-01-01",
mainModule: "plugin.js",
modules: {
"plugin.js": { js: sandboxCode },
},
globalOutbound: null, // Block direct network
env: {
PLUGIN_ID: "sandbox-test",
BRIDGE: bridgeBinding, // Pass bridge as service binding
},
}));
results.push({ step: "Spawn sandbox with bridge", success: true });
// Worker Loader RPC methods are dynamically defined in sandbox code.
// Cast entrypoint to allow calling them without TS errors.
type SandboxRpc = Record<string, (...args: unknown[]) => Promise<Record<string, unknown>>>;
const getEp = () => worker.getEntrypoint("default") as unknown as SandboxRpc;
// Test basic RPC
try {
const ep = getEp();
const testResult = await ep.test();
results.push({
step: "Sandbox basic RPC",
success: testResult?.success === true,
data: testResult
});
} catch (e) {
results.push({ step: "Sandbox basic RPC", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test KV via bridge
try {
const ep = getEp();
const kvResult = await ep.testKv();
results.push({
step: "Sandbox KV via bridge",
success: kvResult?.success === true,
data: kvResult
});
} catch (e) {
results.push({ step: "Sandbox KV via bridge", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test storage via bridge
try {
const ep = getEp();
const storageResult = await ep.testStorage();
results.push({
step: "Sandbox storage via bridge",
success: storageResult?.success === true,
data: storageResult
});
} catch (e) {
results.push({ step: "Sandbox storage via bridge", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test blocked storage
try {
const ep = getEp();
const blockedResult = await ep.testBlockedStorage();
results.push({
step: "Sandbox blocked storage",
success: blockedResult?.blocked === true,
data: blockedResult
});
} catch (e) {
results.push({ step: "Sandbox blocked storage", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test blocked network
try {
const ep = getEp();
const networkResult = await ep.testBlockedNetwork();
results.push({
step: "Sandbox blocked network",
success: networkResult?.blocked === true,
data: networkResult
});
} catch (e) {
results.push({ step: "Sandbox blocked network", success: false, error: e instanceof Error ? e.message : String(e) });
}
// ISOLATION TESTS - verify sandbox can't bypass bridge
// Test direct fetch is blocked (globalOutbound: null)
try {
const ep = getEp();
const fetchResult = await ep.testDirectFetchBlocked();
results.push({
step: "Sandbox direct fetch blocked",
success: fetchResult?.blocked === true,
data: fetchResult
});
} catch (e) {
results.push({ step: "Sandbox direct fetch blocked", success: false, error: e instanceof Error ? e.message : String(e) });
}
// Test sandbox has no DB/MEDIA bindings
try {
const ep = getEp();
const bindingsResult = await ep.testNoDbBinding();
results.push({
step: "Sandbox no direct DB access",
success: bindingsResult?.success === true && !bindingsResult?.hasDb,
data: bindingsResult
});
} catch (e) {
results.push({ step: "Sandbox no direct DB access", success: false, error: e instanceof Error ? e.message : String(e) });
}
} catch (e) {
results.push({ step: "Spawn sandbox with bridge", success: false, error: e instanceof Error ? e.message : String(e) });
}
}
const allPassed = results.every(r => r.success);
const passCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sandbox Plugin Test</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 900px;
margin: 2rem auto;
padding: 1rem;
background: #1a1a1a;
color: #e0e0e0;
}
h1 { color: #fff; }
h2 { color: #ccc; margin-top: 2rem; }
.result {
padding: 1rem;
border-radius: 0.5rem;
margin: 0.75rem 0;
}
.success { background: #1a3d1a; border: 1px solid #2d5a2d; }
.error { background: #3d1a1a; border: 1px solid #5a2d2d; }
.step-name { font-weight: bold; margin-bottom: 0.5rem; }
pre {
background: #2a2a2a;
padding: 1rem;
overflow: auto;
border-radius: 0.25rem;
font-size: 0.85rem;
margin: 0.5rem 0 0 0;
}
.summary {
padding: 1.5rem;
border-radius: 0.5rem;
margin: 1rem 0;
font-size: 1.2rem;
text-align: center;
}
.summary.pass { background: #1a3d1a; border: 2px solid #4a8a4a; }
.summary.fail { background: #3d1a1a; border: 2px solid #8a4a4a; }
.stats { font-size: 0.9rem; margin-top: 0.5rem; color: #aaa; }
</style>
</head>
<body>
<h1>Sandbox Plugin Test</h1>
<div class={`summary ${allPassed ? 'pass' : 'fail'}`}>
{allPassed ? 'All Tests Passed!' : 'Some Tests Failed'}
<div class="stats">{passCount} passed, {failCount} failed</div>
</div>
<h2>Infrastructure</h2>
{results.filter(r => r.step.startsWith("Check")).map(r => (
<div class={`result ${r.success ? 'success' : 'error'}`}>
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
{r.error && <pre>Error: {r.error}</pre>}
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
</div>
))}
<h2>Bridge Direct Tests</h2>
{results.filter(r => r.step.startsWith("Bridge") || r.step.startsWith("Block") || r.step === "Create bridge instance").map(r => (
<div class={`result ${r.success ? 'success' : 'error'}`}>
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
{r.error && <pre>Error: {r.error}</pre>}
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
</div>
))}
<h2>Sandbox Tests (via Worker Loader)</h2>
{results.filter(r => (r.step.startsWith("Sandbox") || r.step.startsWith("Spawn")) && !r.step.includes("direct") && !r.step.includes("no direct")).map(r => (
<div class={`result ${r.success ? 'success' : 'error'}`}>
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
{r.error && <pre>Error: {r.error}</pre>}
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
</div>
))}
<h2>Isolation Tests (sandbox can't bypass bridge)</h2>
{results.filter(r => r.step.includes("direct") || r.step.includes("no direct")).map(r => (
<div class={`result ${r.success ? 'success' : 'error'}`}>
<div class="step-name">{r.success ? '✓' : '✗'} {r.step}</div>
{r.error && <pre>Error: {r.error}</pre>}
{r.data && <pre>{JSON.stringify(r.data, null, 2)}</pre>}
</div>
))}
<h2>Architecture</h2>
<pre>{`
┌─────────────────────────────────────────────────────────────┐
│ HOST WORKER (Astro) │
│ │
│ ┌──────────────────┐ ┌─────────────────────────────┐ │
│ │ PluginBridge │ │ EmDash CMS │ │
│ │ (Entrypoint) │ │ │ │
│ │ │ │ - Routes/Pages │ │
│ │ - kvGet/Set │◄────│ - Middleware │ │
│ │ - storageQuery │ │ - API handlers │ │
│ │ - contentList │ │ │ │
│ │ - httpFetch │ └─────────────────────────────┘ │
│ │ │ │
│ │ (has DB access) │ ┌─────────────────────────────┐ │
│ └────────▲─────────┘ │ Worker Loader │ │
│ │ │ │ │
│ │ RPC │ Spawns sandboxed isolates │ │
│ │ └──────────────┬──────────────┘ │
└───────────┼──────────────────────────────┼──────────────────┘
│ │
│ ▼
┌───────────┴──────────────────────────────────────────────────┐
│ SANDBOX ISOLATE │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Plugin Code │ │
│ │ │ │
│ │ - NO direct DB access │ │
│ │ - NO direct network (globalOutbound: null) │ │
│ │ - Only has BRIDGE service binding │ │
│ │ │ │
│ │ ctx.kv.get() ──► env.BRIDGE.kvGet() ──► Host DB │ │
│ │ ctx.http.fetch() ──► env.BRIDGE.httpFetch() ──► Host │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
`}</pre>
</body>
</html>

View File

@@ -0,0 +1,149 @@
---
/**
* Sandbox Test Page
*
* Tests the Worker Loader functionality by spawning a dynamic isolate.
*/
import { env } from "cloudflare:workers";
interface TestResult {
loaderAvailable: boolean;
isolateSpawned: boolean;
rpcWorked: boolean;
error?: string;
result?: unknown;
}
const results: TestResult = {
loaderAvailable: false,
isolateSpawned: false,
rpcWorked: false,
};
try {
// Check if LOADER binding is available
// @ts-ignore - env typing
const loader = env.LOADER;
results.loaderAvailable = !!loader;
if (loader) {
// Try to spawn a simple isolate
const testCode = `
import { WorkerEntrypoint } from "cloudflare:workers";
export default class TestEntrypoint extends WorkerEntrypoint {
async test(input) {
return {
success: true,
message: "Hello from sandbox!",
received: input,
timestamp: Date.now()
};
}
}
`;
const worker = loader.get("sandbox-test-" + Date.now(), () => ({
compatibilityDate: "2025-01-01",
mainModule: "test.js",
modules: {
"test.js": { js: testCode },
},
globalOutbound: null, // Block network
env: {},
}));
results.isolateSpawned = true;
// Test RPC call — methods are dynamically defined in sandbox code
// @ts-ignore - Worker Loader RPC methods are not statically typed
const entrypoint = worker.getEntrypoint("default");
// @ts-ignore - dynamic RPC method
const rpcResult = await entrypoint.test({ test: "data" });
results.rpcWorked = rpcResult?.success === true;
results.result = rpcResult;
}
} catch (e) {
results.error = e instanceof Error ? e.message : String(e);
}
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Sandbox Test</title>
<style>
body {
font-family: system-ui, sans-serif;
max-width: 800px;
margin: 2rem auto;
padding: 1rem;
}
.result {
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
}
.success {
background: #d4edda;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
}
.pending {
background: #fff3cd;
border: 1px solid #ffeeba;
}
pre {
background: #f4f4f4;
padding: 1rem;
overflow: auto;
border-radius: 0.25rem;
}
</style>
</head>
<body>
<h1>Worker Loader Sandbox Test</h1>
<div class={`result ${results.loaderAvailable ? 'success' : 'error'}`}>
<strong>LOADER Binding:</strong>
{results.loaderAvailable ? 'Available' : 'Not available'}
</div>
<div class={`result ${results.isolateSpawned ? 'success' : results.loaderAvailable ? 'error' : 'pending'}`}>
<strong>Isolate Spawned:</strong>
{results.isolateSpawned ? 'Yes' : 'No'}
</div>
<div class={`result ${results.rpcWorked ? 'success' : results.isolateSpawned ? 'error' : 'pending'}`}>
<strong>RPC Call:</strong>
{results.rpcWorked ? 'Success' : 'Failed'}
</div>
{results.error && (
<div class="result error">
<strong>Error:</strong>
<pre>{results.error}</pre>
</div>
)}
{results.result && (
<div class="result success">
<strong>Result from Sandbox:</strong>
<pre>{JSON.stringify(results.result, null, 2)}</pre>
</div>
)}
<h2>Next Steps</h2>
<p>
If all tests pass, the Worker Loader is working correctly.
This means we can run sandboxed plugins in isolated V8 isolates.
</p>
<h2>Raw Results</h2>
<pre>{JSON.stringify(results, null, 2)}</pre>
</body>
</html>

View File

@@ -0,0 +1,182 @@
---
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: [] };
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<ol class="search-results">
{results.map((result) => (
<li class="search-result">
<a
href={`/posts/${result.slug ?? result.id}`}
class="result-link"
>
<h2 class="result-title">
{result.title ?? "Untitled"}
</h2>
{result.snippet && (
<p class="result-snippet" set:html={result.snippet} />
)}
</a>
</li>
))}
</ol>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.search-result {
padding: var(--spacing-6) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.search-result:first-child {
padding-top: 0;
}
.search-result:last-child {
border-bottom: none;
}
.result-link {
display: block;
text-decoration: none;
color: inherit;
}
.result-title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-accent);
}
.result-snippet {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* FTS returns <mark> wrapping the matched terms */
.result-snippet :global(mark) {
background: var(--color-accent-ring, rgba(99, 102, 241, 0.2));
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,131 @@
---
import {
getTerm,
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 term = slug ? await getTerm("tag", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { tag: term.slug },
orderBy: { published_at: "desc" },
});
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) ?? [],
}));
---
<Base
title={`Posts tagged "${term.label}"`}
description={`All posts tagged with ${term.label}`}
>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Tag</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts with this tag yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,108 @@
/*
theme.css -- override any :root variable here to retheme the blog.
This is the only file you need to edit to customize the site's visual
appearance. All defaults are listed below as comments. Uncomment and
change any value to override it.
Base.astro puts its defaults inside @layer base, so declarations here
(which are unlayered) always take priority -- no specificity tricks needed.
Note: this template defines explicit dark mode colors in Base.astro.
Overriding light-mode --color-* variables here won't affect dark mode.
To customize dark mode, also override --color-* variables inside a
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
*/
:root {
/* --- Colors ---
--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-accent: #0066cc;
--color-accent-hover: #0052a3;
--color-on-accent: white;
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
*/
/* --- 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; used on h1 and large titles
--tracking-snug: -0.02em; used on h2h6, site/card titles
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
--tracking-wider: 0.08em; used on 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;
*/
/* --- Layout ---
--content-width: 680px; article/page body column width
--wide-width: 1200px; max container width (home, archives)
--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-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
*/
/* --- Misc ---
--tag-padding-y: 2px; vertical padding on tag pills
*/
}

View File

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

View File

@@ -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,
};
}

View File

@@ -0,0 +1,18 @@
/**
* Custom Worker Entrypoint for EmDash
*
* Exports:
* - default: Astro handler
* - PluginBridge: WorkerEntrypoint for sandboxed plugin RPC
*/
import handler from "@astrojs/cloudflare/entrypoints/server";
// Re-export PluginBridge from the cloudflare sandbox runtime
// This makes it available via ctx.exports.PluginBridge
export { PluginBridge } from "@emdash-cms/cloudflare/sandbox";
/**
* Default export - just re-export the Astro handler
*/
export default handler;

Some files were not shown because too many files have changed in this diff Show More