first commit

This commit is contained in:
Matt Kane
2026-04-01 10:44:22 +01:00
commit 43fcb9a131
1789 changed files with 395041 additions and 0 deletions

8
.changeset/README.md Normal file
View File

@@ -0,0 +1,8 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)

16
.changeset/config.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
"changelog": [
"@changesets/changelog-github",
{
"repo": "cloudflare/emdash"
}
],
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@demo/*"]
}

1
.claude/CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
../AGENTS.md

1
.claude/skills Symbolic link
View File

@@ -0,0 +1 @@
../skills

6
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

35
.github/workflows/bonk.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Bonk
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
bonk:
if: github.event.sender.type != 'Bot'
runs-on: ubuntu-latest
timeout-minutes: 40
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: false
permissions:
id-token: write
issues: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run Bonk
uses: ask-bonk/ask-bonk/github@c39e982defd0114385df54e72012a3fc4333c4d4 # main 2026-03-23
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CF_AI_GATEWAY_ACCOUNT_ID }}
CLOUDFLARE_GATEWAY_ID: ${{ secrets.CF_AI_GATEWAY_NAME }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CF_AI_GATEWAY_TOKEN }}
with:
model: "cloudflare-ai-gateway/anthropic/claude-opus-4-6"
mentions: "/bonk,@ask-bonk"

203
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,203 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
typecheck:
name: Typecheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm typecheck
- run: pnpm run --filter emdashcms-demo --filter @emdashcms/demo-cloudflare typecheck
- run: pnpm typecheck:templates
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm lint:json
test:
name: Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: emdash_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter emdashcms... build
- run: pnpm test:unit
env:
EMDASH_TEST_PG: postgres://postgres:test@localhost:5432/emdash_test
validate-plugins:
name: Validate Plugins
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter emdashcms... build
- name: Validate marketplace plugins
run: |
CLI="node packages/core/dist/cli/index.mjs"
for dir in packages/plugins/*/; do
[ -f "$dir/package.json" ] || continue
if [ ! -f "$dir/src/sandbox-entry.ts" ] && \
! grep -q '"./sandbox"' "$dir/package.json" 2>/dev/null; then
continue
fi
name=$(basename "$dir")
case "$name" in
marketplace-test|sandboxed-test|api-test) continue ;;
esac
echo "::group::Validating $name"
$CLI plugin bundle --validateOnly --dir "$dir"
echo "::endgroup::"
done
test-smoke:
name: Smoke Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: test
POSTGRES_DB: emdash_smoke
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: pnpm --filter emdashcms exec vitest run --config vitest.smoke.config.ts
timeout-minutes: 5
env:
DATABASE_URL: postgres://postgres:test@localhost:5432/emdash_smoke
test-browser:
name: Browser Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter @emdashcms/admin... build
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm run --filter @emdashcms/admin test
test-e2e:
name: E2E tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shardIndex: [1, 2, 3, 4, 5, 6, 7, 8]
shardTotal: [8]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run --filter emdashcms... build
- uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm exec playwright install --with-deps chromium
if: steps.playwright-cache.outputs.cache-hit != 'true'
- run: pnpm exec playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
if: failure()
with:
name: playwright-report-${{ matrix.shardIndex }}
path: |
playwright-report/
test-results/
retention-days: 7

View File

@@ -0,0 +1,73 @@
name: Seed Marketplace Plugins
on:
workflow_dispatch:
push:
branches: [main]
paths:
- "packages/plugins/**"
- ".github/workflows/deploy-marketplace.yml"
permissions:
contents: read
concurrency:
group: seed-marketplace
cancel-in-progress: false
jobs:
seed:
name: Seed Plugins
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Build core (produces the CLI at dist/cli/index.mjs)
- run: pnpm run --filter emdashcms... build
# Bundle and publish each standard-format plugin.
# Only plugins with a sandbox entry can be marketplace-installed.
# Invokes the built CLI directly via node since pnpm exec can't
# resolve binaries from workspace packages (only from dependencies).
- name: Seed plugins
run: |
CLI="node packages/core/dist/cli/index.mjs"
failures=0
for dir in packages/plugins/*/; do
[ -f "$dir/package.json" ] || continue
# Only include plugins that have a sandbox entry
if [ ! -f "$dir/src/sandbox-entry.ts" ] && \
! grep -q '"./sandbox"' "$dir/package.json" 2>/dev/null; then
continue
fi
name=$(basename "$dir")
# Skip test-only plugins
case "$name" in
marketplace-test|sandboxed-test|api-test) continue ;;
esac
echo "::group::$name"
$CLI plugin publish --build --dir "$dir" --no-wait --registry "$MARKETPLACE_URL" || {
echo "::warning::Failed to publish $name"
failures=$((failures + 1))
}
echo "::endgroup::"
done
if [ "$failures" -gt 0 ]; then
echo "::error::$failures plugin(s) failed to publish"
exit 1
fi
env:
EMDASH_MARKETPLACE_TOKEN: ${{ secrets.MARKETPLACE_SEED_TOKEN }}
MARKETPLACE_URL: https://marketplace.emdashcms.com

26
.github/workflows/format.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Format
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
format:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm format:check

68
.gitignore vendored Normal file
View File

@@ -0,0 +1,68 @@
# build output
dist/
# generated types
.astro/
/themes
# dependencies
node_modules
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
.dev.vars
# database files
*.db
*.db-shm
*.db-wal
*.sqlite
*.sqlite-shm
*.sqlite-wal
# uploads & EmDash data
uploads/
.emdash/
!packages/core/tests/e2e/fixture/.emdash/
!packages/core/tests/integration/fixture/.emdash/
!e2e/fixture/.emdash/
!templates/*/.emdash/seed.json
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/
.wrangler
# Playwright E2E testing
playwright-report/
test-results/
playwright/.cache/
# Debug screenshots
debug-*.png
# Template screenshots - only keep latest/, ignore dated folders
assets/templates/*/[0-9]*
# Vitest browser-mode failure screenshots
__screenshots__/
# AI review artifacts
*-review.md
.chainlink/
.emdash-bundle-tmp
# Downloaded test data (fetched on demand in CI)
examples/wp-theme-unit-test/

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
enable-pre-post-scripts=true

1
.opencode/skills Symbolic link
View File

@@ -0,0 +1 @@
../skills

11
.oxfmtrc.json Normal file
View File

@@ -0,0 +1,11 @@
{
"useTabs": true,
"experimentalSortImports": {},
"ignorePatterns": [
"**/dist/**",
"**/node_modules/**",
"**/*.mdx",
"**/package.json",
"**/emdash-env.d.ts"
]
}

105
.oxlintrc.json Normal file
View File

@@ -0,0 +1,105 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["typescript", "import", "unicorn", "promise"],
"jsPlugins": ["@e18e/eslint-plugin"],
"categories": {
"correctness": "error",
"suspicious": "warn",
"perf": "warn"
},
"rules": {
"no-await-in-loop": "off",
"no-unused-vars": [
"warn",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_"
}
],
"unicorn/filename-case": "off",
"unicorn/prevent-abbreviations": "off",
"unicorn/no-null": "off",
"unicorn/prefer-add-event-listener": "off",
"typescript/no-unsafe-type-assertion": "warn",
"typescript/unbound-method": "off",
"typescript/no-unnecessary-boolean-literal-compare": "off",
"import/no-named-as-default": "off",
"import/no-unassigned-import": [
"warn",
{
"allow": ["**/*.css", "@testing-library/react", "vitest-browser-react"]
}
],
"e18e/prefer-array-at": "error",
"e18e/prefer-array-fill": "error",
"e18e/prefer-includes": "error",
"e18e/prefer-array-to-reversed": "error",
"e18e/prefer-array-to-sorted": "error",
"e18e/prefer-array-to-spliced": "error",
"e18e/prefer-nullish-coalescing": "error",
"e18e/prefer-object-has-own": "error",
"e18e/prefer-spread-syntax": "error",
"e18e/prefer-url-canparse": "error",
"e18e/ban-dependencies": "error",
"e18e/prefer-array-from-map": "error",
"e18e/prefer-timer-args": "error",
"e18e/prefer-date-now": "error",
"e18e/prefer-regex-test": "error",
"e18e/prefer-array-some": "error",
"e18e/prefer-static-regex": "error"
},
"overrides": [
{
"files": ["**/*.test.ts", "**/*.test.tsx", "**/tests/**/*.ts", "**/tests/**/*.tsx"],
"rules": {
"typescript/no-unsafe-type-assertion": "off",
"typescript/no-unnecessary-type-assertion": "off",
"unicorn/consistent-function-scoping": "off",
"e18e(prefer-static-regex)": "off"
}
},
{
"files": [
"**/database/repositories/content.ts",
"**/database/repositories/comment.ts",
"**/database/repositories/user.ts",
"**/mcp/server.ts",
"**/client/index.ts",
"**/client/transport.ts",
"**/client/portable-text.ts",
"**/cli/**/*.ts",
"**/api/handlers/api-tokens.ts",
"**/api/handlers/device-flow.ts",
"**/api/handlers/oauth-authorization.ts",
"**/api/handlers/comments.ts",
"**/routes/api/oauth/token.ts",
"**/routes/api/comments/**/*.ts",
"**/routes/api/admin/comments/**/*.ts",
"**/routes/api/plugins/**/*.ts",
"**/plugins/hooks.ts",
"**/plugins/context.ts",
"**/plugins/cron.ts",
"**/plugins/define-plugin.ts",
"**/plugins/request-meta.ts",
"**/seed/load.ts",
"**/comments/notifications.ts",
"**/astro/integration/index.ts",
"packages/plugins/**/*.ts",
"packages/plugins/**/*.tsx",
"packages/blocks/**/*.tsx",
"packages/admin/**/*.tsx"
],
"rules": {
"typescript/no-unsafe-type-assertion": "off"
}
}
],
"ignorePatterns": [
"**/dist/**",
"**/node_modules/**",
"**/*.d.ts",
"skills/**/scaffold/**",
".opencode/skills/**/scaffold/**",
".claude/skills/**/scaffold/**"
]
}

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
# Prettier is only used for .astro files (oxfmt handles everything else)
*
!**/*.astro

4
.prettierrc Normal file
View File

@@ -0,0 +1,4 @@
{
"useTabs": true,
"plugins": ["prettier-plugin-astro"]
}

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"editor.defaultFormatter": "oxc.oxc-vscode",
"[typescript]": {
"editor.defaultFormatter": "oxc.oxc-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

435
AGENTS.md Normal file
View File

@@ -0,0 +1,435 @@
This file provides guidance to agentic coding tools when working with code in this repository.
## Project Status
**Beta.** EmDash is published to npm. 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 and core APIs
- **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
This is a pre-release project. Do not add backwards compatibility or legacy patterns. Do not deprecate -- remove instead. Do not add migration paths.
**Build for the known future.** If we know we'll need something, build it now. Only defer things where there's genuine uncertainty about whether or how we'll need them. "We'll need it later" is a reason to do it now, not a reason to punt.
**TDD for bugs.** Write a failing test -> fix the bug -> verify the test passes. A bug without a reproducing test is not fixed.
## 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.
- Update tasks.md when completing tasks. Write a journal entry when starting or finishing significant work, or if you learn anything interesting or useful that you'd like to remember.
### 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.
### 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. Open the PR with the `pr` skill
### Dev Servers
Use `bgproc` (not raw process management):
```bash
bgproc start -n devserver -w -- pnpm dev # start and wait for port
bgproc stop devserver # stop
bgproc logs devserver # view logs
```
## Architecture Overview
EmDash is an Astro-native CMS that stores its schema in the database, not in code.
### 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** -- when using the handler layer (`api/handlers/*.ts`), always unwrap consistently:
```typescript
const result = await handler.handleContentGet(collection, id);
if (!result.success) {
return apiError(result.error.code, result.error.message, mapErrorToStatus(result.error.code));
}
return Response.json(result.data);
```
### 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 roles:
```typescript
import { requireRole, Role } from "../../auth/permissions.js";
// At the top of any state-changing handler:
const roleError = requireRole(user, Role.EDITOR);
if (roleError) return roleError;
```
Minimum roles:
- **ADMIN**: settings, schema, plugins, user management, imports, search rebuild
- **EDITOR**: all content CRUD, media, taxonomies, menus, widgets, publish/unpublish
- **AUTHOR**: own content CRUD, media upload
- **CONTRIBUTOR**: own content create/edit (no publish), media upload
### 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/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.
### 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` maps: string/text/datetime/image/reference -> TEXT, number -> REAL, integer/boolean -> INTEGER, portableText/json -> JSON.
- **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 in-memory SQLite via `better-sqlite3` + Kysely. No DB mocking.
- **Utilities:** `tests/utils/test-db.ts` provides `createTestDatabase()`, `setupTestDatabase()` (with migrations), and `setupTestDatabaseWithCollections()` (with standard post/page collections).
- **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 in-memory 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, configured in `.prettierrc`). All source files use tabs, not spaces.
## TypeScript Configuration
- Target: ES2022
- Module: preserve (for bundler compatibility)
- Strict mode with `noUncheckedIndexedAccess`, `noImplicitOverride`
## 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.

208
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,208 @@
# 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 <repo-url> && 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 is kept in sync with `templates/blog/` and uses Node.js + SQLite — no Cloudflare account needed.
```bash
pnpm --filter emdash-demo seed # seed sample content
pnpm --filter emdash-demo dev # http://localhost:4321
```
Open the admin at `http://localhost:4321/_emdash/admin`.
In dev mode, passkey auth is bypassed automatically. If you hit the login screen, visit:
```
http://localhost:4321/_emdash/api/setup/dev-bypass?redirect=/_emdash/admin
```
### 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
# First time: set up database and seed content
pnpm --filter @emdashcms/template-portfolio bootstrap
# Run the dev server
pnpm --filter @emdashcms/template-portfolio dev
```
Available templates:
| Template | Filter Name |
| --------- | ------------------------------ |
| Blog | `@emdashcms/template-blog` |
| Portfolio | `@emdashcms/template-portfolio` |
| Marketing | `@emdashcms/template-marketing` |
Edit files in `templates/{name}/src/` and changes hot reload.
**Cloudflare variants** (`*-cloudflare`) share source with their base templates via `scripts/sync-cloudflare-templates.sh`. Run that script after editing base template shared files.
Demo/template sync is handled by `scripts/sync-blog-demos.sh`:
- Full sync: `templates/blog` -> `demos/simple`
- Frontend sync (keep runtime-specific config/files):
- `templates/blog-cloudflare` -> `demos/cloudflare`
- `templates/blog-cloudflare` -> `demos/preview`
- `templates/blog` -> `demos/postgres`
To start fresh, delete the database and re-bootstrap:
```bash
rm templates/portfolio/data.db
pnpm --filter @emdashcms/template-portfolio bootstrap
```
## Development Workflow
### Watch Mode
For iterating on core packages alongside the demo, run two terminals:
```bash
# Terminal 1 — rebuild packages/core on change
pnpm --filter emdash dev
# Terminal 2 — run the demo
pnpm --filter emdash-demo dev
```
Changes to `packages/core/src/` will be picked up by the demo's dev server automatically.
### Checks
Run these before committing:
```bash
pnpm typecheck # TypeScript (packages)
pnpm typecheck:demos # TypeScript (Astro demos)
pnpm --silent lint:quick # fast lint (< 1s) — run often
pnpm --silent lint:json # full type-aware lint (~10s) — run before commits
pnpm format # auto-format with oxfmt
```
Type checking **must** pass. Lint **must** pass. Don't commit with known failures.
### Tests
```bash
pnpm test # all packages
pnpm --filter emdash test # core only
pnpm --filter emdash test --watch # watch mode
pnpm test:e2e # Playwright (requires demo running)
```
Tests use real in-memory SQLite — no mocking. Each test gets a fresh database.
## Repository Layout
```
emdash/
├── packages/
│ ├── core/ # emdash — the main package (Astro integration + APIs + admin)
│ ├── auth/ # @emdashcms/auth — passkeys, OAuth, magic links
│ ├── admin/ # @emdashcms/admin — React admin SPA
│ ├── cloudflare/ # @emdashcms/cloudflare — CF adapter + plugin sandbox
│ ├── create-emdash/ # create-emdash — project scaffolder
│ ├── gutenberg-to-portable-text/ # WP block → Portable Text converter
│ └── plugins/ # first-party plugins (each dir = package)
├── demos/
│ ├── simple/ # emdash-demo — primary dev/test app (Node.js + SQLite)
│ ├── cloudflare/ # Cloudflare Workers demo (D1)
│ ├── plugins-demo/ # plugin development testbed
│ └── ...
├── templates/ # starter templates (blog, portfolio, marketing + cloudflare variants)
├── docs/ # public documentation site (Starlight)
└── e2e/ # Playwright test fixtures
```
The main package is **`packages/core`**. Most of your work will happen there.
## Building Your Own Site (Inside the Monorepo)
The easiest way to build a real site during development is to add it as a workspace member.
1. Copy `templates/blog/` (or `templates/blank/`) into `demos/`:
```bash
cp -r templates/blog demos/my-site
```
2. Edit `demos/my-site/package.json` — set a unique `name` field.
3. Run `pnpm install` from the root to link workspace dependencies.
4. Start developing:
```bash
pnpm --filter my-site dev
```
Your site will use `workspace:*` links to the local packages, so any changes you make to core will be 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`.
## Commits and PRs
- Branch from `main`.
- Commit messages: describe _why_, not just _what_.
- Ensure `pnpm typecheck` and `pnpm --silent lint:json` pass before pushing.
- Run relevant tests.
## What's Intentionally Missing (For Now)
These are known gaps -- don't try to fix them unless specifically asked:
- **Rate limiting** -- no brute-force protection on auth endpoints
- **Password auth** -- passkeys + magic links + OAuth only, by design
- **Plugin marketplace** -- architecture exists, runtime installation is post-beta
- **Real-time collaboration** -- planned for v1
## 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

207
NOTES.md Normal file
View File

@@ -0,0 +1,207 @@
# 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
```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/cloudflare/emdash/tree/main/templates/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 classic blog with sidebar widgets, search, and RSS.
- Categories & tags
- Full-text search
- RSS feed
- Comment-ready
- Dark/light mode
<a href="assets/templates/blog/latest/"><img src="assets/templates/blog/latest/homepage-light-desktop.jpg" alt="Blog template" width="100%"></a>
</td>
<td width="33%" valign="top">
### Marketing
A conversion-focused landing page with pricing and contact form.
- Hero with CTAs
- Feature grid
- Pricing cards
- FAQ accordion
- Contact form
<a href="assets/templates/marketing/latest/"><img src="assets/templates/marketing/latest/homepage-light-desktop.jpg" alt="Marketing template" width="100%"></a>
</td>
<td width="33%" valign="top">
### Portfolio
A visual portfolio for showcasing creative work.
- Project grid
- Tag filtering
- Case study pages
- RSS feed
- Dark/light mode
<a href="assets/templates/portfolio/latest/"><img src="assets/templates/portfolio/latest/work-light-desktop.jpg" alt="Portfolio template" width="100%"></a>
</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/the-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/cloudflare/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

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,100 @@
// @ts-check
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import {
d1,
r2,
access,
sandbox,
cloudflareCache,
cloudflareImages,
cloudflareStream,
} from "@emdashcms/cloudflare";
import { formsPlugin } from "@emdashcms/plugin-forms";
import { webhookNotifierPlugin } from "@emdashcms/plugin-webhook-notifier";
import { defineConfig } 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,
},
},
},
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": "@emdashcms/demo-cloudflare",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build:all": "pnpm run --filter @emdashcms/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:",
"@emdashcms/cloudflare": "workspace:*",
"@emdashcms/plugin-forms": "workspace:*",
"@emdashcms/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-2);
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-muted);
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>

View File

@@ -0,0 +1,985 @@
---
import { getMenu, getEmDashCollection } from "emdash";
import {
WidgetArea,
EmDashHead,
EmDashBodyStart,
EmDashBodyEnd,
} from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
import "../styles/theme.css";
interface Props {
title: string;
description?: string | null;
image?: string | null;
canonical?: string | null;
robots?: string | null;
type?: "website" | "article";
publishedTime?: string | null;
modifiedTime?: string | null;
author?: string | null;
/** Pass content reference for plugin page contributions on content pages */
content?: { collection: string; id: string; slug?: string | null };
}
const {
title,
description,
image,
canonical,
robots,
type = "website",
publishedTime,
modifiedTime,
author,
content,
} = Astro.props;
const siteTitle = "My Blog";
// If title already includes site title (from getSeoMeta), use as-is
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
// Fetch primary menu defined in seed
const menu = await getMenu("primary");
// Fetch pages for footer
const { entries: pages } = await getEmDashCollection("pages");
// Build public page context for plugin contributions
// SEO data is passed here and rendered securely by EmDashHead
const pageCtx = createPublicPageContext({
Astro,
kind: content ? "content" : "custom",
pageType: type,
title: fullTitle,
description,
canonical,
image,
content,
seo: { ogImage: image, robots },
articleMeta: { publishedTime, modifiedTime, author },
siteName: siteTitle,
});
// Check if user is logged in (for showing admin link)
const isLoggedIn = !!Astro.locals.user;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<title>{fullTitle}</title>
<EmDashHead page={pageCtx} />
<script is:inline>
// Apply theme immediately to prevent flash
(function () {
var c = document.cookie;
var i = c.indexOf("theme=");
var theme = i >= 0 ? c.slice(i + 6).split(";")[0] : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
document.documentElement.classList.add("dark");
}
})();
</script>
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header class="site-header">
<nav class="nav">
<a href="/" class="site-title">{siteTitle}</a>
<div class="nav-right">
<LiveSearch
placeholder="Search..."
class="site-search"
inputClass="site-search-input"
resultsClass="site-search-results"
resultClass="site-search-result"
collections={["posts", "pages"]}
/>
<div class="nav-links">
{
menu?.items.map((item) => (
<a href={item.url} target={item.target}>
{item.label}
</a>
))
}
</div>
{
isLoggedIn && (
<a href="/_emdash/admin" class="nav-admin">
Admin
</a>
)
}
</div>
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="footer-inner">
<div class="footer-grid">
<div class="footer-brand">
<a href="/" class="footer-logo">{siteTitle}</a>
<p class="footer-tagline">Thoughts, stories, and ideas.</p>
</div>
<div class="footer-nav">
<h4 class="footer-heading">Navigate</h4>
<ul class="footer-links">
<li><a href="/">Home</a></li>
<li><a href="/posts">All Posts</a></li>
{
pages.slice(0, 3).map((page) => (
<li>
<a href={`/pages/${page.data.slug || page.id}`}>
{page.data.title}
</a>
</li>
))
}
</ul>
</div>
<div class="footer-nav">
<h4 class="footer-heading">Connect</h4>
<ul class="footer-links">
{
menu?.items.map((item) => (
<li>
<a
href={item.url}
target={item.target}
rel={
item.target === "_blank"
? "noopener noreferrer"
: undefined
}
>
{item.label}
</a>
</li>
))
}
<li><a href="/rss.xml">RSS Feed</a></li>
</ul>
</div>
<div class="footer-widgets-section">
<WidgetArea name="footer" />
</div>
</div>
<div class="footer-bottom">
<p class="footer-copyright">
Powered by <a href="https://emdashcms.com">EmDash</a>
</p>
<div class="theme-switcher">
<button
type="button"
class="theme-btn"
data-theme="light"
aria-label="Light mode"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><circle cx="12" cy="12" r="5"></circle><line
x1="12"
y1="1"
x2="12"
y2="3"></line><line x1="12" y1="21" x2="12" y2="23"
></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"
></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"
></line><line x1="1" y1="12" x2="3" y2="12"></line><line
x1="21"
y1="12"
x2="23"
y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"
></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"
></line></svg
>
</button>
<button
type="button"
class="theme-btn"
data-theme="dark"
aria-label="Dark mode"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"
></path></svg
>
</button>
<button
type="button"
class="theme-btn"
data-theme="system"
aria-label="System theme"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><rect x="2" y="3" width="20" height="14" rx="2" ry="2"
></rect><line x1="8" y1="21" x2="16" y2="21"></line><line
x1="12"
y1="17"
x2="12"
y2="21"></line></svg
>
</button>
</div>
</div>
</div>
</footer>
<script>
// Theme switcher
const THEME_REGEX = /theme=([^;]+)/;
const themeBtns =
document.querySelectorAll<HTMLButtonElement>(".theme-btn");
const root = document.documentElement;
function setCookie(
name: string,
value: string,
maxAge: number = 31536000
) {
const secure = location.protocol === "https:" ? "; Secure" : "";
if (value === "") {
document.cookie = `${name}=; path=/; max-age=0; SameSite=Lax${secure}`;
} else {
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax${secure}`;
}
}
function setTheme(theme: string) {
if (theme === "system") {
setCookie("theme", "");
root.classList.remove("light", "dark");
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
root.classList.add("dark");
}
} else {
setCookie("theme", theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
}
updateActiveBtn(theme);
}
function updateActiveBtn(theme: string) {
themeBtns.forEach((btn) => {
btn.classList.toggle("active", btn.dataset.theme === theme);
});
}
function getStoredTheme(): string {
const match = document.cookie.match(THEME_REGEX);
return match ? match[1] : "system";
}
// Initialize
const storedTheme = getStoredTheme();
setTheme(storedTheme);
themeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
setTheme(btn.dataset.theme || "system");
});
});
// Listen for system preference changes
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (getStoredTheme() === "system") {
root.classList.toggle("dark", e.matches);
}
});
</script>
<style is:global>
*:where(:not([class*="emdash"]):not([class*="ec-"])),
*:where(:not([class*="emdash"]):not([class*="ec-"]))::before,
*:where(:not([class*="emdash"]):not([class*="ec-"]))::after {
box-sizing: border-box;
}
body,
h1,
h2,
h3,
h4,
h5,
h6,
p,
ul,
ol,
figure,
blockquote,
dl,
dd {
margin: 0;
}
ul,
ol {
padding: 0;
}
:root {
/* Colors - Light mode (default) */
--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
);
/* EmDash search theming */
--emdash-search-bg: var(--color-bg);
--emdash-search-text: var(--color-text);
--emdash-search-muted: var(--color-muted);
--emdash-search-border: var(--color-border);
--emdash-search-hover: var(--color-surface);
--emdash-search-highlight: var(--color-text);
/* Typography */
--font-sans:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
--font-mono:
"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
/* Type scale - more refined */
--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;
/* Spacing - more generous */
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
/* Legacy spacing aliases */
--spacing-xs: var(--spacing-1);
--spacing-sm: var(--spacing-2);
--spacing-md: var(--spacing-4);
--spacing-lg: var(--spacing-6);
--spacing-xl: var(--spacing-8);
--spacing-2xl: var(--spacing-12);
--spacing-3xl: var(--spacing-16);
/* Layout - wider for three-column */
--content-width: 680px;
--wide-width: 1200px;
--max-width: var(--content-width);
--gutter-width: 200px;
--radius: 4px;
--radius-lg: 8px;
/* Transitions */
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
/* Nav */
--nav-height: 64px;
/* Search */
--search-input-width: 180px;
/* Article layout */
--meta-col-width: 180px;
/* Avatar sizes */
--avatar-size-xs: 18px;
--avatar-size-sm: 20px;
--avatar-size-md: 24px;
--avatar-size-lg: 32px;
/* Letter spacing */
--tracking-tight: -0.03em;
--tracking-snug: -0.02em;
--tracking-wide: 0.06em;
--tracking-wider: 0.08em;
/* Tag pill */
--tag-padding-y: 2px;
/* Shadows */
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Dark mode via system preference (when no explicit class) */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--color-bg: #0d0d0d;
--color-bg-subtle: #141414;
--color-text: #ededed;
--color-text-secondary: #a0a0a0;
--color-muted: #6b6b6b;
--color-border: #2a2a2a;
--color-border-subtle: #1f1f1f;
--color-surface: #181818;
--color-accent: #4d9fff;
--color-accent-hover: #6eb0ff;
}
}
/* Explicit dark mode */
:root.dark {
--color-bg: #0d0d0d;
--color-bg-subtle: #141414;
--color-text: #ededed;
--color-text-secondary: #a0a0a0;
--color-muted: #6b6b6b;
--color-border: #2a2a2a;
--color-border-subtle: #1f1f1f;
--color-surface: #181818;
--color-accent: #4d9fff;
--color-accent-hover: #6eb0ff;
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-sans);
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text);
background: var(--color-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
min-height: 100vh;
}
a:where(:not([class*="emdash"]):not([class*="ec-"])) {
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
a:where(:not([class*="emdash"]):not([class*="ec-"])):hover {
color: var(--color-accent-hover);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-sans);
line-height: var(--leading-tight);
font-weight: 600;
letter-spacing: var(--tracking-snug);
}
h1 {
font-weight: 700;
letter-spacing: var(--tracking-tight);
}
::selection {
background: var(--color-accent);
color: white;
}
</style>
<style>
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: color-mix(in srgb, var(--color-bg) 65%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-bottom: 1px solid var(--color-border-subtle);
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-4) var(--spacing-6);
height: var(--nav-height);
}
.site-title {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: var(--tracking-snug);
}
.site-title:hover {
color: var(--color-accent);
}
.nav-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
}
.nav-links {
display: flex;
gap: var(--spacing-5);
font-size: var(--font-size-sm);
}
.nav-links a {
text-decoration: none;
color: var(--color-text);
transition: color var(--transition-fast);
}
.nav-links a:hover {
color: var(--color-accent);
}
.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-muted);
text-decoration: none;
opacity: 0.5;
transition: opacity var(--transition-fast);
}
.nav-admin:hover {
opacity: 1;
}
/* Search styling */
.site-search {
position: relative;
width: var(--search-input-width);
--emdash-search-border-focus: var(--color-accent);
}
:global(.site-search-input) {
width: var(--search-input-width);
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);
}
:global(.site-search-input)::placeholder {
color: var(--color-muted);
}
:global(.site-search-input):focus,
:global(.site-search-input):focus-visible {
outline: none;
border-color: var(--color-accent) !important;
box-shadow: 0 0 0 3px var(--color-accent-ring);
}
:global(.site-search-results) {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: var(--spacing-2);
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-dropdown);
max-height: 400px;
overflow-y: auto;
z-index: 1000;
}
:global(.site-search-results .emdash-live-search-loading),
:global(.site-search-results .emdash-live-search-no-results) {
padding: var(--spacing-4);
text-align: center;
color: var(--color-muted);
font-size: var(--font-size-sm);
}
:global(.site-search-result) {
display: block;
padding: var(--spacing-3) var(--spacing-4);
text-decoration: none;
color: var(--color-text);
border-bottom: 1px solid var(--color-border-subtle);
transition: background var(--transition-fast);
}
:global(.site-search-result):last-child {
border-bottom: none;
}
:global(.site-search-result):hover,
:global(.site-search-result):focus,
:global(.site-search-result.focused) {
background: var(--color-surface);
outline: none;
}
:global(.site-search-result .emdash-live-search-result-title) {
display: block;
font-weight: 500;
font-size: var(--font-size-sm);
}
:global(.site-search-result .emdash-live-search-result-collection) {
display: block;
font-size: var(--font-size-xs);
color: var(--color-muted);
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
margin-top: 2px;
}
:global(.site-search-result .emdash-live-search-result-snippet) {
display: block;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-top: var(--spacing-1);
line-height: var(--leading-snug);
}
:global(.site-search-result .emdash-live-search-result-snippet mark) {
font-weight: 600;
color: var(--color-text);
}
main {
min-height: calc(100vh - var(--nav-height) - 300px);
}
/* Footer */
.site-footer {
background: var(--color-bg-subtle);
border-top: 1px solid var(--color-border-subtle);
}
.footer-inner {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6) var(--spacing-8);
}
.footer-grid {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: var(--spacing-12);
margin-bottom: var(--spacing-12);
}
.footer-brand {
max-width: 280px;
}
.footer-logo {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text);
text-decoration: none;
letter-spacing: var(--tracking-snug);
}
.footer-tagline {
margin-top: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
line-height: var(--leading-relaxed);
}
.footer-nav {
min-width: 0;
}
.footer-heading {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.footer-links {
list-style: none;
}
.footer-links li {
margin-bottom: var(--spacing-2);
}
.footer-links a {
color: var(--color-text-secondary);
text-decoration: none;
font-size: var(--font-size-sm);
transition: color var(--transition-fast);
}
.footer-links a:hover {
color: var(--color-text);
}
.footer-widgets-section :global(.widget-area) {
display: block;
}
.footer-widgets-section :global(.widget) {
color: var(--color-text-secondary);
}
.footer-widgets-section :global(.widget__title) {
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.footer-widgets-section :global(.widget__content) {
color: var(--color-text-secondary);
font-size: var(--font-size-sm);
line-height: var(--leading-relaxed);
}
.footer-bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: var(--spacing-6);
border-top: 1px solid var(--color-border);
}
.footer-copyright {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.footer-copyright a {
color: var(--color-text-secondary);
}
/* Theme switcher */
.theme-switcher {
display: flex;
gap: var(--spacing-1);
padding: var(--spacing-1);
background: var(--color-surface);
border-radius: var(--radius);
}
.theme-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 28px;
background: transparent;
border: none;
color: var(--color-muted);
border-radius: var(--radius);
cursor: pointer;
transition: all var(--transition-fast);
}
.theme-btn:hover {
color: var(--color-text-secondary);
}
.theme-btn.active {
background: var(--color-bg);
color: var(--color-text);
box-shadow: var(--shadow-btn-active);
}
.theme-btn svg {
width: 16px;
height: 16px;
}
@media (max-width: 900px) {
.footer-grid {
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8);
}
.footer-brand {
grid-column: span 2;
max-width: none;
}
}
@media (max-width: 640px) {
.site-header {
position: relative;
border-bottom: none;
}
.nav {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
height: auto;
gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
.site-title {
font-size: var(--font-size-base);
}
.nav-right {
display: contents;
}
.site-search {
order: 0;
max-width: 140px;
}
:global(.site-search-input) {
width: 140px !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}
.nav-links {
order: 1;
width: 100%;
display: flex;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-admin {
order: 2;
position: absolute;
right: var(--spacing-4);
top: var(--spacing-3);
font-size: var(--font-size-xs);
}
.footer-grid {
grid-template-columns: 1fr;
}
.footer-brand {
grid-column: span 1;
}
.footer-bottom {
flex-direction: column;
gap: var(--spacing-4);
text-align: center;
}
.footer-controls {
flex-wrap: wrap;
justify-content: center;
}
}
</style>
<script>
// ⌘K / Ctrl+K to focus search
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
const searchInput = document.querySelector(
".site-search-input"
) as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}
});
</script>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>

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,117 @@
---
import { getTerm, getEmDashCollection, getEntryTerms } from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const { slug } = Astro.params;
const term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
);
---
<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>

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