first commit
8
.changeset/README.md
Normal 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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
../AGENTS.md
|
||||||
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../skills
|
||||||
6
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
35
.github/workflows/bonk.yml
vendored
Normal 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
@@ -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
|
||||||
73
.github/workflows/deploy-marketplace.yml
vendored
Normal 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
@@ -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
@@ -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
.opencode/skills
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
../skills
|
||||||
11
.oxfmtrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"experimentalSortImports": {},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"**/dist/**",
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/*.mdx",
|
||||||
|
"**/package.json",
|
||||||
|
"**/emdash-env.d.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
105
.oxlintrc.json
Normal 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
@@ -0,0 +1,3 @@
|
|||||||
|
# Prettier is only used for .astro files (oxfmt handles everything else)
|
||||||
|
*
|
||||||
|
!**/*.astro
|
||||||
4
.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"plugins": ["prettier-plugin-astro"]
|
||||||
|
}
|
||||||
7
.vscode/settings.json
vendored
Normal 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
@@ -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
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
[](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
@@ -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
|
||||||
|
```
|
||||||
BIN
assets/templates/blog/latest/404-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/templates/blog/latest/404-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/templates/blog/latest/404-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
assets/templates/blog/latest/404-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
assets/templates/blog/latest/about-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/templates/blog/latest/about-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/templates/blog/latest/about-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 114 KiB |
BIN
assets/templates/blog/latest/about-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
assets/templates/blog/latest/homepage-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/templates/blog/latest/homepage-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 104 KiB |
BIN
assets/templates/blog/latest/homepage-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 289 KiB |
BIN
assets/templates/blog/latest/homepage-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
assets/templates/blog/latest/post-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 287 KiB |
BIN
assets/templates/blog/latest/post-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/templates/blog/latest/post-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 292 KiB |
BIN
assets/templates/blog/latest/post-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
BIN
assets/templates/blog/latest/posts-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
assets/templates/blog/latest/posts-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
assets/templates/blog/latest/posts-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
assets/templates/blog/latest/posts-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
assets/templates/blog/latest/search-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
assets/templates/blog/latest/search-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
assets/templates/blog/latest/search-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 244 KiB |
BIN
assets/templates/blog/latest/search-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
assets/templates/marketing/latest/404-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
assets/templates/marketing/latest/404-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
assets/templates/marketing/latest/404-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
assets/templates/marketing/latest/404-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
assets/templates/marketing/latest/contact-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
assets/templates/marketing/latest/contact-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
assets/templates/marketing/latest/contact-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/templates/marketing/latest/contact-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
assets/templates/marketing/latest/homepage-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
assets/templates/marketing/latest/homepage-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
assets/templates/marketing/latest/homepage-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
assets/templates/marketing/latest/homepage-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
assets/templates/marketing/latest/pricing-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
assets/templates/marketing/latest/pricing-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
assets/templates/marketing/latest/pricing-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 180 KiB |
BIN
assets/templates/marketing/latest/pricing-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
assets/templates/portfolio/latest/404-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
assets/templates/portfolio/latest/404-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
assets/templates/portfolio/latest/404-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/templates/portfolio/latest/404-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
assets/templates/portfolio/latest/contact-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
assets/templates/portfolio/latest/contact-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
assets/templates/portfolio/latest/contact-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
assets/templates/portfolio/latest/contact-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
assets/templates/portfolio/latest/homepage-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
assets/templates/portfolio/latest/homepage-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
assets/templates/portfolio/latest/homepage-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
assets/templates/portfolio/latest/homepage-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
assets/templates/portfolio/latest/project-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/templates/portfolio/latest/project-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
assets/templates/portfolio/latest/project-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/templates/portfolio/latest/project-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
assets/templates/portfolio/latest/work-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
assets/templates/portfolio/latest/work-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
assets/templates/portfolio/latest/work-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
assets/templates/portfolio/latest/work-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
assets/templates/portfolio/latest/work-tagged-dark-desktop.jpg
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
assets/templates/portfolio/latest/work-tagged-dark-mobile.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
assets/templates/portfolio/latest/work-tagged-light-desktop.jpg
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
assets/templates/portfolio/latest/work-tagged-light-mobile.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
63
demos/cloudflare/README.md
Normal 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)
|
||||||
100
demos/cloudflare/astro.config.mjs
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
demos/cloudflare/package.json
Normal 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": {}
|
||||||
|
}
|
||||||
13
demos/cloudflare/plugins/sandbox-test/manifest.json
Normal 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"]
|
||||||
|
}
|
||||||
41
demos/cloudflare/scripts/reset-db.sh
Executable 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"
|
||||||
53
demos/cloudflare/scripts/reset-db.sql
Normal 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
|
||||||
778
demos/cloudflare/seed/seed.json
Normal 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."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
279
demos/cloudflare/src/components/PostCard.astro
Normal 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>
|
||||||
45
demos/cloudflare/src/components/TagList.astro
Normal 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>
|
||||||
985
demos/cloudflare/src/layouts/Base.astro
Normal 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>
|
||||||
13
demos/cloudflare/src/live.config.ts
Normal 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() }),
|
||||||
|
};
|
||||||
33
demos/cloudflare/src/pages/404.astro
Normal 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>
|
||||||
31
demos/cloudflare/src/pages/als-test.astro
Normal 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>
|
||||||
117
demos/cloudflare/src/pages/category/[slug].astro
Normal 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>
|
||||||