Emdash source with visual editor image upload fix

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

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

View File

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

View File

@@ -0,0 +1,129 @@
---
import {
getTerm,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
// per post).
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,463 @@
---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
---
import { getEmDashCollection, getTermsForEntries } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
// Sort in the database rather than in JS — lets the DB use its index on
// published_at and avoids a full-table scan on the client.
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags across all posts, instead of one
// getEntryTerms() call per post (which would be N round-trips).
// Bylines are already hydrated on entry.data.bylines.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const postsWithTags = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
bylines: post.data.bylines ?? [],
}));
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title">{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-accent);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const postUrl = `${siteUrl}/posts/${post.id}`;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteTagline)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

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

View File

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

View File

@@ -0,0 +1,182 @@
---
export const prerender = false;
import { search } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q")?.trim() || "";
// Use the FTS-backed search() API instead of loading every post and
// filtering in JS. FTS scales as the post count grows, returns ranked
// results, and handles tokenization/stemming. Templates that grep all
// post bodies in JS quickly become unusable past a few hundred posts.
const { items: results } = query
? await search(query, { collections: ["posts"], limit: 30 })
: { items: [] };
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<ol class="search-results">
{results.map((result) => (
<li class="search-result">
<a
href={`/posts/${result.slug ?? result.id}`}
class="result-link"
>
<h2 class="result-title">
{result.title ?? "Untitled"}
</h2>
{result.snippet && (
<p class="result-snippet" set:html={result.snippet} />
)}
</a>
</li>
))}
</ol>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.search-result {
padding: var(--spacing-6) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.search-result:first-child {
padding-top: 0;
}
.search-result:last-child {
border-bottom: none;
}
.result-link {
display: block;
text-decoration: none;
color: inherit;
}
.result-title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-accent);
}
.result-snippet {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* FTS returns <mark> wrapping the matched terms */
.result-snippet :global(mark) {
background: var(--color-accent-ring, rgba(99, 102, 241, 0.2));
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>

View File

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

View File

@@ -0,0 +1,108 @@
/*
theme.css -- override any :root variable here to retheme the blog.
This is the only file you need to edit to customize the site's visual
appearance. All defaults are listed below as comments. Uncomment and
change any value to override it.
Base.astro puts its defaults inside @layer base, so declarations here
(which are unlayered) always take priority -- no specificity tricks needed.
Note: this template defines explicit dark mode colors in Base.astro.
Overriding light-mode --color-* variables here won't affect dark mode.
To customize dark mode, also override --color-* variables inside a
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
*/
:root {
/* --- Colors ---
--color-bg: #ffffff;
--color-bg-subtle: #fafafa;
--color-text: #1a1a1a;
--color-text-secondary: #525252;
--color-muted: #8b8b8b;
--color-border: #e5e5e5;
--color-border-subtle: #f0f0f0;
--color-surface: #f7f7f7;
--color-accent: #0066cc;
--color-accent-hover: #0052a3;
--color-on-accent: white;
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
*/
/* --- Type scale ---
--font-size-xs: 0.8125rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
*/
/* --- Line heights ---
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
*/
/* --- Letter spacing ---
--tracking-tight: -0.03em; used on h1 and large titles
--tracking-snug: -0.02em; used on h2h6, site/card titles
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
--tracking-wider: 0.08em; used on footer headings, section labels
*/
/* --- Spacing ---
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
*/
/* --- Layout ---
--content-width: 680px; article/page body column width
--wide-width: 1200px; max container width (home, archives)
--gutter-width: 200px; right sidebar column (TOC) on article pages
--meta-col-width: 180px; left meta column on article pages
--nav-height: 64px; sticky header height
--search-input-width: 180px; nav search box width
*/
/* --- Borders & radius ---
--radius: 4px;
--radius-lg: 8px;
*/
/* --- Transitions ---
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
*/
/* --- Avatars ---
--avatar-size-xs: 18px; card byline avatars
--avatar-size-sm: 20px; post list byline avatars
--avatar-size-md: 24px; featured post byline avatars
--avatar-size-lg: 32px; single post byline avatars
*/
/* --- Shadows ---
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
*/
/* --- Misc ---
--tag-padding-y: 2px; vertical padding on tag pills
*/
}

View File

@@ -0,0 +1,66 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const CJK_CHARACTERS_PER_MINUTE = 500;
const WHITESPACE_REGEX = /\s+/;
const CJK_CHARACTER_REGEX =
/\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
type PortableTextSpan = {
_type: string;
text?: string;
};
type PortableTextTextBlock = PortableTextBlock & {
_type: "block";
children: PortableTextSpan[];
};
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block" && Array.isArray(block.children);
}
function countWords(text: string): number {
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
}
function countCjkCharacters(text: string): number {
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
}
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(isTextBlock)
.map((block) =>
block.children
.filter((child) => child._type === "span" && typeof child.text === "string")
.map((span) => span.text)
.join(""),
)
.join(" ");
}
/**
* Calculate reading time in minutes from Portable Text content
*/
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
const text = extractText(content);
const cjkCharacterCount = countCjkCharacters(text);
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
const minutes = Math.ceil(
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}

View File

@@ -0,0 +1,24 @@
/** Resolved media reference from getSiteSettings() */
export interface MediaReference {
mediaId: string;
alt?: string;
url?: string;
}
export interface BlogSiteIdentitySettings {
title?: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
siteLogo: settings?.logo?.url ? settings.logo : null,
};
}

View File

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

View File

@@ -0,0 +1,4 @@
{
"extends": "astro/tsconfigs/strict",
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts", "worker-configuration.d.ts"]
}

12056
demos/cloudflare/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,41 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "emdash-demo",
"main": "./src/worker.ts",
"compatibility_date": "2026-01-14",
// disable_nodejs_process_v2 needed until unenv fix lands in Pages
// See: https://github.com/withastro/astro/issues/14511
"compatibility_flags": ["nodejs_compat", "disable_nodejs_process_v2"],
// Static assets served from dist/
"routes": [
{
"pattern": "demo.emdashcms.com",
"zone_name": "demo.emdashcms.com",
"custom_domain": true,
},
],
// D1 Database binding
"d1_databases": [
{
"binding": "DB",
"database_name": "emdash_db",
},
],
// R2 bucket for media storage
"r2_buckets": [
{
"binding": "MEDIA",
"bucket_name": "emdash-media",
},
],
// Observability
"observability": {
"enabled": true,
},
// Worker Loader for plugin sandboxing
"worker_loaders": [
{
"binding": "LOADER",
},
],
}

5
demos/playground/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.astro/
.wrangler/
worker-configuration.d.ts

View File

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

View File

@@ -0,0 +1,44 @@
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { playgroundDatabase } from "@emdash-cms/cloudflare";
import { defineConfig, fontProviders } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
image: {
layout: "constrained",
responsiveStyles: true,
},
integrations: [
react(),
emdash({
// Playground uses a DO-backed database, not D1
database: playgroundDatabase({ binding: "PLAYGROUND_DB" }),
// No storage -- media uploads are blocked in playground mode
// Playground mode: injects playground middleware before runtime init,
// skips setup/auth (handled by playground middleware)
playground: {
middlewareEntrypoint: "@emdash-cms/cloudflare/db/playground-middleware",
},
}),
],
fonts: [
{
provider: fontProviders.google(),
name: "Inter",
cssVariable: "--font-sans",
weights: [400, 500, 600, 700],
fallbacks: ["sans-serif"],
},
{
provider: fontProviders.google(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 500],
fallbacks: ["monospace"],
},
],
devToolbar: { enabled: false },
});

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

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

View File

@@ -0,0 +1,30 @@
{
"name": "@emdash-cms/playground",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"deploy": "wrangler deploy",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/cloudflare": "workspace:*",
"astro": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@cloudflare/workers-types": "catalog:",
"wrangler": "catalog:"
},
"emdash": {
"seed": "seed/seed.json"
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

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

View File

@@ -0,0 +1,129 @@
---
import {
getTerm,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
// per post).
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,463 @@
---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
---
import { getEmDashCollection, getTermsForEntries } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
// Sort in the database rather than in JS — lets the DB use its index on
// published_at and avoids a full-table scan on the client.
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags across all posts, instead of one
// getEntryTerms() call per post (which would be N round-trips).
// Bylines are already hydrated on entry.data.bylines.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const postsWithTags = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
bylines: post.data.bylines ?? [],
}));
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title">{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-accent);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const postUrl = `${siteUrl}/posts/${post.id}`;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteTagline)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

@@ -0,0 +1,182 @@
---
export const prerender = false;
import { search } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q")?.trim() || "";
// Use the FTS-backed search() API instead of loading every post and
// filtering in JS. FTS scales as the post count grows, returns ranked
// results, and handles tokenization/stemming. Templates that grep all
// post bodies in JS quickly become unusable past a few hundred posts.
const { items: results } = query
? await search(query, { collections: ["posts"], limit: 30 })
: { items: [] };
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<ol class="search-results">
{results.map((result) => (
<li class="search-result">
<a
href={`/posts/${result.slug ?? result.id}`}
class="result-link"
>
<h2 class="result-title">
{result.title ?? "Untitled"}
</h2>
{result.snippet && (
<p class="result-snippet" set:html={result.snippet} />
)}
</a>
</li>
))}
</ol>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.search-result {
padding: var(--spacing-6) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.search-result:first-child {
padding-top: 0;
}
.search-result:last-child {
border-bottom: none;
}
.result-link {
display: block;
text-decoration: none;
color: inherit;
}
.result-title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-accent);
}
.result-snippet {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* FTS returns <mark> wrapping the matched terms */
.result-snippet :global(mark) {
background: var(--color-accent-ring, rgba(99, 102, 241, 0.2));
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>

View File

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

View File

@@ -0,0 +1,108 @@
/*
theme.css -- override any :root variable here to retheme the blog.
This is the only file you need to edit to customize the site's visual
appearance. All defaults are listed below as comments. Uncomment and
change any value to override it.
Base.astro puts its defaults inside @layer base, so declarations here
(which are unlayered) always take priority -- no specificity tricks needed.
Note: this template defines explicit dark mode colors in Base.astro.
Overriding light-mode --color-* variables here won't affect dark mode.
To customize dark mode, also override --color-* variables inside a
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
*/
:root {
/* --- Colors ---
--color-bg: #ffffff;
--color-bg-subtle: #fafafa;
--color-text: #1a1a1a;
--color-text-secondary: #525252;
--color-muted: #8b8b8b;
--color-border: #e5e5e5;
--color-border-subtle: #f0f0f0;
--color-surface: #f7f7f7;
--color-accent: #0066cc;
--color-accent-hover: #0052a3;
--color-on-accent: white;
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
*/
/* --- Type scale ---
--font-size-xs: 0.8125rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
*/
/* --- Line heights ---
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
*/
/* --- Letter spacing ---
--tracking-tight: -0.03em; used on h1 and large titles
--tracking-snug: -0.02em; used on h2h6, site/card titles
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
--tracking-wider: 0.08em; used on footer headings, section labels
*/
/* --- Spacing ---
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
*/
/* --- Layout ---
--content-width: 680px; article/page body column width
--wide-width: 1200px; max container width (home, archives)
--gutter-width: 200px; right sidebar column (TOC) on article pages
--meta-col-width: 180px; left meta column on article pages
--nav-height: 64px; sticky header height
--search-input-width: 180px; nav search box width
*/
/* --- Borders & radius ---
--radius: 4px;
--radius-lg: 8px;
*/
/* --- Transitions ---
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
*/
/* --- Avatars ---
--avatar-size-xs: 18px; card byline avatars
--avatar-size-sm: 20px; post list byline avatars
--avatar-size-md: 24px; featured post byline avatars
--avatar-size-lg: 32px; single post byline avatars
*/
/* --- Shadows ---
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
*/
/* --- Misc ---
--tag-padding-y: 2px; vertical padding on tag pills
*/
}

View File

@@ -0,0 +1,66 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const CJK_CHARACTERS_PER_MINUTE = 500;
const WHITESPACE_REGEX = /\s+/;
const CJK_CHARACTER_REGEX =
/\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
type PortableTextSpan = {
_type: string;
text?: string;
};
type PortableTextTextBlock = PortableTextBlock & {
_type: "block";
children: PortableTextSpan[];
};
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block" && Array.isArray(block.children);
}
function countWords(text: string): number {
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
}
function countCjkCharacters(text: string): number {
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
}
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(isTextBlock)
.map((block) =>
block.children
.filter((child) => child._type === "span" && typeof child.text === "string")
.map((span) => span.text)
.join(""),
)
.join(" ");
}
/**
* Calculate reading time in minutes from Portable Text content
*/
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
const text = extractText(content);
const cjkCharacterCount = countCjkCharacters(text);
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
const minutes = Math.ceil(
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}

View File

@@ -0,0 +1,24 @@
/** Resolved media reference from getSiteSettings() */
export interface MediaReference {
mediaId: string;
alt?: string;
url?: string;
}
export interface BlogSiteIdentitySettings {
title?: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
siteLogo: settings?.logo?.url ? settings.logo : null,
};
}

View File

@@ -0,0 +1,14 @@
/**
* Playground Worker Entrypoint
*
* Exports:
* - default: Astro handler
* - EmDashPreviewDB: Durable Object class for playground databases
*/
import handler from "@astrojs/cloudflare/entrypoints/server";
// Export the DO class so Cloudflare can instantiate it
export { EmDashPreviewDB } from "@emdash-cms/cloudflare/db/playground";
export default handler;

View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
}

View File

@@ -0,0 +1,39 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "emdash-playground",
"compatibility_date": "2026-02-24",
"compatibility_flags": ["nodejs_compat"],
// Custom entrypoint that exports EmDashPreviewDB
"main": "./src/worker.ts",
"durable_objects": {
"bindings": [
{
"name": "PLAYGROUND_DB",
"class_name": "EmDashPreviewDB",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["EmDashPreviewDB"],
},
],
"observability": {
"enabled": true,
},
"routes": [
{
"pattern": "emdashcms.com",
"zone_name": "emdashcms.com",
"custom_domain": true,
},
{
"pattern": "www.emdashcms.com",
"zone_name": "emdashcms.com",
"custom_domain": true,
},
],
// No R2 -- media uploads are blocked in playground mode
// No D1 -- database is inside the Durable Object
}

View File

@@ -0,0 +1,23 @@
# emdash-plugins-demo
## 0.0.3
### Patch Changes
- Updated dependencies [[`3c319ed`](https://github.com/emdash-cms/emdash/commit/3c319ed6411a595e6974a86bc58c2a308b91c214)]:
- emdash@0.0.3
- @emdash-cms/plugin-api-test@0.0.3
- @emdash-cms/plugin-audit-log@0.0.3
- @emdash-cms/plugin-embeds@0.0.3
- @emdash-cms/plugin-webhook-notifier@0.0.3
## 0.0.2
### Patch Changes
- Updated dependencies [[`b09bfd5`](https://github.com/emdash-cms/emdash/commit/b09bfd51cece5e88fe8314668a591ab11de36b4d)]:
- emdash@0.0.2
- @emdash-cms/plugin-api-test@0.0.2
- @emdash-cms/plugin-audit-log@0.0.2
- @emdash-cms/plugin-embeds@0.0.2
- @emdash-cms/plugin-webhook-notifier@0.0.2

View File

@@ -0,0 +1,70 @@
# EmDash Plugins Demo
This demo showcases EmDash's plugin system with plugins that demonstrate the hook architecture.
## Plugins Included
### 1. Audit Log Plugin (`@emdash-cms/plugin-audit-log`)
Tracks all content changes for compliance.
- **Hooks:**
- `content:beforeSave` (priority 1) - captures "before" state
- `content:afterSave` (priority 200) - logs final state
- `content:beforeDelete` (priority 200) - logs deletions
- `media:afterUpload` (priority 200) - logs uploads
- **Features:**
- Create/update/delete tracking
- Before/after state comparison
- Admin history page
### 2. Webhook Notifier Plugin (`@emdash-cms/plugin-webhook-notifier`)
Posts JSON payloads to external webhook URLs on content/media events.
- **Hooks:** `content:afterSave`, `content:afterDelete`, `media:afterUpload` (priority 210)
- **Features:**
- Retry with exponential backoff
- Admin-configurable settings (URL, secret token)
- SSRF protection
- Delivery tracking
### 3. Embeds Plugin (`@emdash-cms/plugin-embeds`)
Provides Portable Text block types for embedding external content.
- **Features:**
- YouTube, Vimeo, Twitter/X, Bluesky, Mastodon embeds
- Link previews (Open Graph)
- GitHub Gist embeds
### 4. API Test Plugin (`@emdash-cms/plugin-api-test`)
Exercises all v2 plugin APIs for testing.
- **Features:**
- Routes for each plugin API (kv, storage, content, media, http)
- Combined `test/all` route
## Running the Demo
```bash
# Install dependencies
pnpm install
# Seed sample content
pnpm seed
# Start development server
pnpm dev
# Open browser
open http://localhost:4321
```
## Testing Plugin Hooks
1. Open the admin at `http://localhost:4321/_emdash/admin`
2. Create a new post with a title like "Hello World! Testing Plugins"
3. Watch the console output to see hooks executing:
- `[audit-log] + create content/posts/post-xxx`

View File

@@ -0,0 +1,44 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { apiTestPlugin } from "@emdash-cms/plugin-api-test";
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
import { embedsPlugin } from "@emdash-cms/plugin-embeds";
import { webhookNotifierPlugin } from "@emdash-cms/plugin-webhook-notifier";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
import { sqlite } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
integrations: [
react(),
emdash({
// SQLite database for demo
database: sqlite({ url: "file:./data.db" }),
// Register plugins - order matters for hook execution!
plugins: [
// 1. Audit log runs last (priority 200) to capture final state
// Settings (retention, data changes, excluded collections) are
// configured at runtime via the admin UI, not constructor options.
auditLogPlugin(),
// 2. Webhook notifier sends events to external URLs
// Demonstrates: network:fetch:any, apiRoutes, settings.secret(),
// hook dependencies, errorPolicy: "continue"
// Webhook URL, collections, and actions are configured via admin settings.
webhookNotifierPlugin(),
// 3. Embeds plugin for YouTube, Vimeo, Twitter, etc.
// Components are auto-registered with PortableText
embedsPlugin(),
// 4. API Test plugin - exercises all v2 APIs
apiTestPlugin(),
],
}),
],
});

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

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

View File

@@ -0,0 +1,34 @@
{
"name": "emdash-plugins-demo",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"seed": "emdash seed",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/node": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/plugin-audit-log": "workspace:*",
"@emdash-cms/plugin-api-test": "workspace:*",
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
"@emdash-cms/plugin-embeds": "workspace:*",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"better-sqlite3": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:"
},
"peerDependencies": {},
"optionalDependencies": {}
}

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

@@ -0,0 +1,182 @@
---
// Demo homepage showcasing plugin functionality
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
}
.subtitle {
color: #666;
font-size: 1.25rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #333;
}
.plugin-list {
list-style: none;
}
.plugin {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.plugin h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: #2563eb;
}
.plugin p {
color: #555;
margin-bottom: 0.75rem;
}
.plugin code {
background: #e2e8f0;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875rem;
}
.hooks {
margin-top: 0.5rem;
font-size: 0.875rem;
}
.hooks strong {
color: #333;
}
.cta {
display: inline-block;
background: #2563eb;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
text-decoration: none;
font-weight: 500;
margin-top: 1.5rem;
}
.cta:hover {
background: #1d4ed8;
}
.order {
background: #dbeafe;
color: #1e40af;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 0.5rem;
}
</style>
</head>
<body>
<h1>EmDash Plugins Demo</h1>
<p class="subtitle">
Demonstrating the plugin hook system with realistic plugins
</p>
<h2>Active Plugins</h2>
<ul class="plugin-list">
<li class="plugin">
<h3>Auto-Slug <span class="order">Priority 10</span></h3>
<p>
Automatically generates URL-friendly slugs from titles. Handles unicode,
removes special characters, and ensures clean URLs.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>SEO Demo <span class="order">Priority 50</span></h3>
<p>
Validates SEO fields (meta title, description) against length limits.
Provides admin UI for SEO settings and dashboard widget.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>Reading Time <span class="order">Priority 80</span></h3>
<p>
Calculates reading time based on word count and images. Parses Portable
Text content and stores result in the content data.
</p>
<div class="hooks">
<strong>Hook:</strong> <code>content:beforeSave</code>
</div>
</li>
<li class="plugin">
<h3>Audit Log <span class="order">Priority 200</span></h3>
<p>
Tracks all content changes for compliance and debugging. Logs create,
update, delete operations with timestamps.
</p>
<div class="hooks">
<strong>Hooks:</strong>
<code>content:beforeSave</code>,
<code>content:afterSave</code>,
<code>content:beforeDelete</code>,
<code>media:afterUpload</code>
</div>
</li>
<li class="plugin">
<h3>Image Optimizer <span class="order">Priority 10</span></h3>
<p>
Validates image uploads (file type, size limits). Sanitizes filenames
and adds timestamps for uniqueness.
</p>
<div class="hooks">
<strong>Hooks:</strong>
<code>media:beforeUpload</code>,
<code>media:afterUpload</code>
</div>
</li>
</ul>
<h2>Hook Execution Order</h2>
<p>
When saving content, hooks execute in priority order (lower numbers first):
</p>
<ol style="margin: 1rem 0 1rem 1.5rem;">
<li><strong>Auto-Slug (10)</strong> - Generates slug from title</li>
<li><strong>Audit Log (1)</strong> - Captures "before" state for comparison</li>
<li><strong>SEO (50)</strong> - Validates SEO field lengths</li>
<li><strong>Reading Time (80)</strong> - Calculates reading time</li>
<li><strong>Audit Log (200)</strong> - Logs the final saved state</li>
</ol>
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<a href="/_emdash/admin" class="cta">Open Admin Panel</a>
<a href="/posts" class="cta" style="background: #059669;">View Posts</a>
<a href="/test-embeds" class="cta" style="background: #7c3aed;">Test Embeds</a>
</div>
</body>
</html>

View File

@@ -0,0 +1,176 @@
---
/**
* Individual post page with PortableText rendering
*
* This demonstrates the embeds plugin auto-registering components
* for YouTube, Vimeo, etc. with the PortableText renderer.
*/
import { getEmDashEntry, decodeSlug } from "emdash";
import { PortableText } from "emdash/ui";
import { embedComponents } from "@emdash-cms/plugin-embeds/astro";
const slug = decodeSlug(Astro.params.slug);
const { entry: post } = slug
? await getEmDashEntry("posts", slug)
: { entry: null };
if (!post) {
return Astro.redirect("/404");
}
const title = post.data.title;
const content = post.data.content;
const metaDescription = post.data.excerpt;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{title || "Post"} - EmDash Plugins Demo</title>
{metaDescription && <meta name="description" content={metaDescription} />}
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.meta {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 2rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: #2563eb;
}
/* Content styles */
.content {
margin-top: 2rem;
}
.content h1 {
font-size: 1.75rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.content h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
}
.content h3 {
font-size: 1.25rem;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.content p {
margin-bottom: 1rem;
}
.content ul,
.content ol {
margin-bottom: 1rem;
padding-left: 1.5rem;
}
.content li {
margin-bottom: 0.5rem;
}
.content blockquote {
border-left: 4px solid #e5e7eb;
padding-left: 1rem;
margin: 1rem 0;
color: #4b5563;
font-style: italic;
}
.content pre {
background: #f3f4f6;
padding: 1rem;
border-radius: 8px;
overflow-x: auto;
margin: 1rem 0;
}
.content code {
background: #f3f4f6;
padding: 0.125rem 0.375rem;
border-radius: 4px;
font-size: 0.875em;
}
.content pre code {
background: none;
padding: 0;
}
.content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 1rem 0;
}
.content a {
color: #2563eb;
}
/* Embed styles */
.content iframe {
max-width: 100%;
border-radius: 8px;
margin: 1rem 0;
}
footer {
margin-top: 3rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
font-size: 0.875rem;
}
footer a {
color: #2563eb;
}
</style>
</head>
<body>
<a href="/posts" class="back">&larr; Back to posts</a>
<article>
<h1>{title || "Untitled"}</h1>
<div class="meta">
{post.data.status === "draft" && <span>Draft</span>}
</div>
<div class="content">
{
Array.isArray(content) && content.length > 0 ? (
<PortableText
value={content}
components={{ type: embedComponents }}
/>
) : typeof content === "string" && content ? (
<p>{content}</p>
) : (
<p style="color: #6b7280; font-style: italic;">No content yet.</p>
)
}
</div>
</article>
<footer>
<a href={`/_emdash/admin/content/posts/${post.id}`}>Edit in Admin</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,113 @@
---
/**
* Posts listing page
*/
import { getEmDashCollection } from "emdash";
const { entries: posts } = await getEmDashCollection("posts");
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Posts - EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
.back {
display: inline-block;
margin-bottom: 1rem;
color: #2563eb;
}
.post-list {
list-style: none;
}
.post-item {
border-bottom: 1px solid #e5e7eb;
padding: 1.5rem 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-title {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.post-title a {
color: #2563eb;
text-decoration: none;
}
.post-title a:hover {
text-decoration: underline;
}
.post-meta {
font-size: 0.875rem;
color: #6b7280;
}
.empty {
color: #6b7280;
padding: 2rem;
text-align: center;
background: #f9fafb;
border-radius: 8px;
}
</style>
</head>
<body>
<a href="/" class="back">&larr; Back to home</a>
<h1>Posts</h1>
{
posts.length === 0 ? (
<div class="empty">
<p>No posts yet.</p>
<p>
<a href="/_emdash/admin/content/posts/new">
Create your first post
</a>
</p>
</div>
) : (
<ul class="post-list">
{posts.map((post) => (
<li class="post-item">
<h2 class="post-title">
<a href={`/posts/${post.data.slug || post.id}`}>
{post.data.title || "Untitled"}
</a>
</h2>
<div class="post-meta">
{post.data.status === "draft" && <span>Draft</span>}
</div>
</li>
))}
</ul>
)
}
<footer
style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; font-size: 0.875rem;"
>
<a href="/_emdash/admin" style="color: #2563eb;">Open Admin</a>
</footer>
</body>
</html>

View File

@@ -0,0 +1,129 @@
---
/**
* Test page for embed components
*
* This page renders hardcoded Portable Text with embed blocks.
*/
import { PortableText } from "emdash/ui";
import { embedComponents } from "@emdash-cms/plugin-embeds/astro";
// Sample Portable Text content with various embed types
const testContent = [
{
_type: "block",
_key: "intro",
style: "normal",
children: [
{
_type: "span",
text: "This page tests the auto-registered embed components. If you see the embeds below, the virtual module system is working!",
},
],
},
{
_type: "block",
_key: "h1",
style: "h2",
children: [{ _type: "span", text: "YouTube Embed" }],
},
{
_type: "youtube",
_key: "yt1",
id: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
},
{
_type: "block",
_key: "h2",
style: "h2",
children: [{ _type: "span", text: "Vimeo Embed" }],
},
{
_type: "vimeo",
_key: "vim1",
id: "https://vimeo.com/76979871",
},
{
_type: "block",
_key: "h3",
style: "h2",
children: [{ _type: "span", text: "Link Preview" }],
},
{
_type: "linkPreview",
_key: "lp1",
id: "https://astro.build",
},
{
_type: "block",
_key: "outro",
style: "normal",
children: [
{
_type: "span",
text: "If you see the embeds above rendered correctly, the plugin system is working! No manual component wiring was needed.",
},
],
},
];
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>Test Embeds - EmDash Plugins Demo</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
system-ui,
-apple-system,
sans-serif;
line-height: 1.6;
color: #1a1a1a;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2rem;
margin-bottom: 1rem;
}
h2 {
font-size: 1.5rem;
margin-top: 2rem;
margin-bottom: 1rem;
color: #333;
}
p {
margin-bottom: 1rem;
}
.content {
margin-top: 2rem;
}
a {
color: #2563eb;
}
.back {
display: inline-block;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<a href="/" class="back">&larr; Back to home</a>
<h1>Embed Components Test</h1>
<p>This page tests embed components from the embeds plugin.</p>
<div class="content">
<PortableText
value={testContent}
components={{ type: embedComponents }}
/>
</div>
</body>
</html>

View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true
},
"include": ["src/**/*", "astro.config.mjs", "emdash-env.d.ts"]
}

View File

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

View File

@@ -0,0 +1,37 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { defineConfig, fontProviders } from "astro/config";
import emdash from "emdash/astro";
import { postgres } from "emdash/db";
export default defineConfig({
output: "server",
adapter: node({
mode: "standalone",
}),
integrations: [
react(),
emdash({
database: postgres({
connectionString: process.env.DATABASE_URL || "postgres://localhost:5432/emdash_dev",
}),
}),
],
fonts: [
{
provider: fontProviders.google(),
name: "Inter",
cssVariable: "--font-sans",
weights: [400, 500, 600, 700],
fallbacks: ["sans-serif"],
},
{
provider: fontProviders.google(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 500],
fallbacks: ["monospace"],
},
],
devToolbar: { enabled: false },
});

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

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

View File

@@ -0,0 +1,30 @@
{
"name": "@emdash-cms/demo-postgres",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"start": "node ./dist/server/entry.mjs",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/node": "catalog:",
"@astrojs/react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"emdash": "workspace:*",
"pg": "^8.0.0",
"react": "catalog:",
"react-dom": "catalog:"
},
"emdash": {
"seed": "seed/seed.json"
},
"devDependencies": {
"@types/node": "catalog:"
}
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

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

View File

@@ -0,0 +1,129 @@
---
import {
getTerm,
getEmDashCollection,
getTermsForEntries,
decodeSlug,
} from "emdash";
import Base from "../../layouts/Base.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = decodeSlug(Astro.params.slug);
const term = slug ? await getTerm("category", slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags on every post in this category, rather
// than calling getEntryTerms() per post (which would be one round-trip
// per post).
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<Base title={`${term.label} posts`} description={`All posts in ${term.label}`}>
<section class="archive-section">
<header class="archive-header">
<span class="archive-label">Category</span>
<h1 class="archive-title">{term.label}</h1>
<p class="archive-count">
{filteredPosts.length}
{filteredPosts.length === 1 ? "post" : "posts"}
</p>
</header>
{
filteredPosts.length === 0 ? (
<p class="no-posts">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
title={post.data.title}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
/>
))}
</div>
)
}
</section>
</Base>
<style>
.archive-section {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-12) var(--spacing-6);
}
.archive-header {
margin-bottom: var(--spacing-12);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border-subtle);
}
.archive-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
color: var(--color-accent);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.archive-count {
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
.no-posts {
color: var(--color-muted);
}
@media (max-width: 900px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,463 @@
---
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
Astro.cache.set(cacheHint);
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
// Find the first post with a featured image for the hero
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");
const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));
// Format date helper
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<Base title={siteTitle} description={siteTagline}>
{
posts.length === 0 ? (
<section class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin/content/posts/new" class="btn">
Create a post
</a>
</section>
) : (
<div class="home-content">
{/* Featured Post - Side by side */}
{featuredPost && (
<section class="featured-section">
<div class="featured-grid">
<a href={`/posts/${featuredPost.id}`} class="featured-image-link">
<div class="featured-image">
<Image image={featuredPost.data.featured_image} />
</div>
</a>
<div class="featured-content">
<div class="featured-meta">
{featuredBylines.length > 0 && (
<>
<div class="featured-bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="featured-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="featured-byline-avatar"
/>
)}
<span class="featured-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{featuredBylines.length > 2 && (
<span class="byline-more">
+{featuredBylines.length - 2}
</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span class="meta-dot" />
<span>
{getReadingTime(featuredPost.data.content)} min read
</span>
</div>
<a
href={`/posts/${featuredPost.id}`}
class="featured-title-link"
>
<h1 class="featured-title">{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt">{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="featured-tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`} class="featured-tag">
{tag.label}
</a>
))}
</div>
)}
</div>
</div>
</section>
)}
{/* Latest Posts */}
{gridPostsWithTags.length > 0 && (
<section class="posts-section">
<header class="section-header">
<h2 class="section-title">Latest</h2>
{hasMorePosts && (
<a href="/posts" class="section-link">
View all
</a>
)}
</header>
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
title={post.data.title ?? "Untitled"}
excerpt={post.data.excerpt}
featuredImage={post.data.featured_image}
href={`/posts/${post.id}`}
date={post.data.publishedAt ?? undefined}
readingTime={getReadingTime(post.data.content)}
tags={tags}
bylines={bylines}
/>
))}
</div>
</section>
)}
</div>
)
}
</Base>
<style>
.home-content {
max-width: var(--wide-width);
margin: 0 auto;
padding: var(--spacing-16) var(--spacing-6);
}
/* Featured Section - Side by side */
.featured-section {
margin-bottom: var(--spacing-16);
}
.featured-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
align-items: center;
}
.featured-image-link {
grid-column: 1 / 3;
display: block;
/* Extend to viewport edge, but cap at -6rem minimum extension */
margin-left: min(
-6rem,
calc(-1 * (var(--spacing-6) + (100vw - var(--wide-width)) / 2))
);
}
.featured-image {
overflow: hidden;
border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
background: var(--color-surface);
}
.featured-image img {
width: 100%;
height: auto;
aspect-ratio: 4 / 3;
object-fit: cover;
transition: transform 0.4s ease;
}
.featured-image-link:hover .featured-image img,
.featured-grid:has(.featured-title-link:hover) .featured-image img {
transform: scale(1.02);
}
.featured-content {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.featured-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Featured bylines */
.featured-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.featured-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-2);
}
.featured-byline-avatar {
width: var(--avatar-size-md);
height: var(--avatar-size-md);
border-radius: 50%;
object-fit: cover;
}
.featured-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.featured-title-link {
text-decoration: none;
color: inherit;
}
.featured-title {
font-size: clamp(var(--font-size-2xl), 4vw, var(--font-size-4xl));
font-weight: 700;
line-height: var(--leading-tight);
letter-spacing: var(--tracking-tight);
transition: color var(--transition-fast);
}
.featured-title-link:hover .featured-title,
.featured-grid:has(.featured-image-link:hover) .featured-title {
color: var(--color-accent);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-accent);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-accent-hover);
}
/* Posts Grid */
.posts-section {
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-12) var(--spacing-8);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-3);
text-align: center;
padding: var(--spacing-20) var(--spacing-6);
max-width: 400px;
margin: 0 auto;
}
.empty-state h2 {
font-size: var(--font-size-2xl);
font-weight: 600;
}
.empty-state p {
color: var(--color-muted);
}
.btn {
display: inline-block;
margin-top: var(--spacing-4);
padding: var(--spacing-3) var(--spacing-6);
background: var(--color-accent);
color: var(--color-on-accent);
text-decoration: none;
border-radius: var(--radius);
font-size: var(--font-size-sm);
font-weight: 500;
transition: background var(--transition-fast);
}
.btn:hover {
background: var(--color-accent-hover);
}
/* Responsive */
@media (max-width: 900px) {
.home-content {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.featured-image-link {
margin-left: 0;
}
.featured-grid {
grid-template-columns: 1fr;
gap: var(--spacing-6);
}
.featured-image {
border-radius: var(--radius-lg);
}
.featured-image img {
aspect-ratio: 16 / 9;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-8) var(--spacing-6);
}
}
@media (max-width: 600px) {
.featured-title {
font-size: var(--font-size-2xl);
}
.posts-grid {
grid-template-columns: 1fr;
gap: var(--spacing-8);
}
}
</style>

View File

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

View File

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

View File

@@ -0,0 +1,272 @@
---
import { getEmDashCollection, getTermsForEntries } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
// Sort in the database rather than in JS — lets the DB use its index on
// published_at and avoids a full-table scan on the client.
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
// Single batched query for tags across all posts, instead of one
// getEntryTerms() call per post (which would be N round-trips).
// Bylines are already hydrated on entry.data.bylines.
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const postsWithTags = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
bylines: post.data.bylines ?? [],
}));
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
---
<Base title="All Posts" description="Browse all blog posts">
<div class="posts-page">
<header class="page-header">
<h1 class="page-title">All Posts</h1>
<p class="page-description">
{posts.length}
{posts.length === 1 ? "article" : "articles"}
</p>
</header>
{
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-item">
<a href={`/posts/${post.id}`} class="post-link">
<div class="post-meta">
{bylines.length > 0 && (
<>
<div class="post-bylines">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span class="byline-sep">,</span>}
<span class="post-byline">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="post-byline-avatar"
/>
)}
<span class="post-byline-name">
{credit.byline.displayName}
</span>
</span>
</>
))}
{bylines.length > 2 && (
<span class="byline-more">+{bylines.length - 2}</span>
)}
</div>
<span class="meta-dot" />
</>
)}
{post.data.publishedAt && (
<time>{formatDate(post.data.publishedAt)}</time>
)}
{post.data.publishedAt && <span class="meta-dot" />}
<span>{getReadingTime(post.data.content)} min read</span>
</div>
<h2 class="post-title">{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
</a>
{tags.length > 0 && (
<div class="post-tags">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="post-tag">
{t.label}
</a>
))}
</div>
)}
</article>
))}
</div>
)
}
</div>
</Base>
<style>
.posts-page {
max-width: var(--content-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.page-header {
margin-bottom: var(--spacing-12);
}
.page-title {
font-size: var(--font-size-4xl);
font-weight: 700;
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.page-description {
font-size: var(--font-size-lg);
color: var(--color-muted);
}
.empty {
color: var(--color-muted);
font-size: var(--font-size-lg);
}
.posts-list {
display: flex;
flex-direction: column;
}
.post-item {
padding: var(--spacing-8) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.post-item:first-child {
padding-top: 0;
}
.post-item:last-child {
border-bottom: none;
}
.post-link {
display: block;
text-decoration: none;
color: inherit;
}
.post-meta {
display: flex;
align-items: center;
gap: var(--spacing-3);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
/* Post bylines */
.post-bylines {
display: flex;
align-items: center;
gap: 2px;
}
.post-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.post-byline-avatar {
width: var(--avatar-size-sm);
height: var(--avatar-size-sm);
border-radius: 50%;
object-fit: cover;
}
.post-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-sep {
color: var(--color-muted);
margin-right: 2px;
}
.byline-more {
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
}
.post-title {
font-size: var(--font-size-2xl);
font-weight: 600;
line-height: var(--leading-snug);
letter-spacing: var(--tracking-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.post-link:hover .post-title {
color: var(--color-accent);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
const { entries: posts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
});
const items = posts
.map((post) => {
if (!post.data.publishedAt) return null;
const pubDate = post.data.publishedAt.toUTCString();
const postUrl = `${siteUrl}/posts/${post.id}`;
const title = escapeXml(post.data.title || "Untitled");
const description = escapeXml(post.data.excerpt || "");
return ` <item>
<title>${title}</title>
<link>${postUrl}</link>
<guid isPermaLink="true">${postUrl}</guid>
<pubDate>${pubDate}</pubDate>
<description>${description}</description>
</item>`;
})
.filter(Boolean)
.join("\n");
const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${escapeXml(siteTitle)}</title>
<description>${escapeXml(siteTagline)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

@@ -0,0 +1,182 @@
---
export const prerender = false;
import { search } from "emdash";
import Base from "../layouts/Base.astro";
const query = Astro.url.searchParams.get("q")?.trim() || "";
// Use the FTS-backed search() API instead of loading every post and
// filtering in JS. FTS scales as the post count grows, returns ranked
// results, and handles tokenization/stemming. Templates that grep all
// post bodies in JS quickly become unusable past a few hundred posts.
const { items: results } = query
? await search(query, { collections: ["posts"], limit: 30 })
: { items: [] };
---
<Base
title={query ? `Search: ${query}` : "Search"}
description="Search blog posts"
>
<section class="search-page">
<h1 class="search-title">Search</h1>
<form method="get" action="/search" class="search-form">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
class="search-input"
autofocus
/>
<button type="submit" class="search-button">Search</button>
</form>
{
query && (
<p class="search-summary">
{results.length === 0
? `No results for "${query}"`
: `${results.length} result${results.length === 1 ? "" : "s"} for "${query}"`}
</p>
)
}
{
results.length > 0 && (
<ol class="search-results">
{results.map((result) => (
<li class="search-result">
<a
href={`/posts/${result.slug ?? result.id}`}
class="result-link"
>
<h2 class="result-title">
{result.title ?? "Untitled"}
</h2>
{result.snippet && (
<p class="result-snippet" set:html={result.snippet} />
)}
</a>
</li>
))}
</ol>
)
}
{!query && <p class="search-hint">Enter a search term to find posts.</p>}
</section>
</Base>
<style>
.search-page {
max-width: var(--max-width);
margin: 0 auto;
padding: var(--spacing-8) var(--spacing-6) var(--spacing-16);
}
.search-title {
font-size: var(--font-size-2xl);
margin-bottom: var(--spacing-6);
}
.search-form {
display: flex;
gap: var(--spacing-2);
margin-bottom: var(--spacing-8);
}
.search-input {
flex: 1;
padding: var(--spacing-2) var(--spacing-4);
font-size: var(--font-size-base);
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-bg);
color: var(--color-text);
}
.search-input:focus {
outline: none;
border-color: var(--color-accent);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-accent);
color: var(--color-on-accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
font-weight: 500;
}
.search-button:hover {
opacity: 0.9;
}
.search-summary {
color: var(--color-muted);
margin-bottom: var(--spacing-6);
}
.search-hint {
color: var(--color-muted);
}
.search-results {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
}
.search-result {
padding: var(--spacing-6) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.search-result:first-child {
padding-top: 0;
}
.search-result:last-child {
border-bottom: none;
}
.result-link {
display: block;
text-decoration: none;
color: inherit;
}
.result-title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-accent);
}
.result-snippet {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
/* FTS returns <mark> wrapping the matched terms */
.result-snippet :global(mark) {
background: var(--color-accent-ring, rgba(99, 102, 241, 0.2));
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>

View File

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

View File

@@ -0,0 +1,108 @@
/*
theme.css -- override any :root variable here to retheme the blog.
This is the only file you need to edit to customize the site's visual
appearance. All defaults are listed below as comments. Uncomment and
change any value to override it.
Base.astro puts its defaults inside @layer base, so declarations here
(which are unlayered) always take priority -- no specificity tricks needed.
Note: this template defines explicit dark mode colors in Base.astro.
Overriding light-mode --color-* variables here won't affect dark mode.
To customize dark mode, also override --color-* variables inside a
@media (prefers-color-scheme: dark) block and/or in the :root.dark rule.
*/
:root {
/* --- Colors ---
--color-bg: #ffffff;
--color-bg-subtle: #fafafa;
--color-text: #1a1a1a;
--color-text-secondary: #525252;
--color-muted: #8b8b8b;
--color-border: #e5e5e5;
--color-border-subtle: #f0f0f0;
--color-surface: #f7f7f7;
--color-accent: #0066cc;
--color-accent-hover: #0052a3;
--color-on-accent: white;
--color-accent-ring: color-mix(in srgb, var(--color-accent) 25%, transparent);
*/
/* --- Type scale ---
--font-size-xs: 0.8125rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
*/
/* --- Line heights ---
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
*/
/* --- Letter spacing ---
--tracking-tight: -0.03em; used on h1 and large titles
--tracking-snug: -0.02em; used on h2h6, site/card titles
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
--tracking-wider: 0.08em; used on footer headings, section labels
*/
/* --- Spacing ---
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
*/
/* --- Layout ---
--content-width: 680px; article/page body column width
--wide-width: 1200px; max container width (home, archives)
--gutter-width: 200px; right sidebar column (TOC) on article pages
--meta-col-width: 180px; left meta column on article pages
--nav-height: 64px; sticky header height
--search-input-width: 180px; nav search box width
*/
/* --- Borders & radius ---
--radius: 4px;
--radius-lg: 8px;
*/
/* --- Transitions ---
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
*/
/* --- Avatars ---
--avatar-size-xs: 18px; card byline avatars
--avatar-size-sm: 20px; post list byline avatars
--avatar-size-md: 24px; featured post byline avatars
--avatar-size-lg: 32px; single post byline avatars
*/
/* --- Shadows ---
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
*/
/* --- Misc ---
--tag-padding-y: 2px; vertical padding on tag pills
*/
}

View File

@@ -0,0 +1,66 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const CJK_CHARACTERS_PER_MINUTE = 500;
const WHITESPACE_REGEX = /\s+/;
const CJK_CHARACTER_REGEX =
/\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
type PortableTextSpan = {
_type: string;
text?: string;
};
type PortableTextTextBlock = PortableTextBlock & {
_type: "block";
children: PortableTextSpan[];
};
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block" && Array.isArray(block.children);
}
function countWords(text: string): number {
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
}
function countCjkCharacters(text: string): number {
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
}
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(isTextBlock)
.map((block) =>
block.children
.filter((child) => child._type === "span" && typeof child.text === "string")
.map((span) => span.text)
.join(""),
)
.join(" ");
}
/**
* Calculate reading time in minutes from Portable Text content
*/
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
const text = extractText(content);
const cjkCharacterCount = countCjkCharacters(text);
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
const minutes = Math.ceil(
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}

View File

@@ -0,0 +1,24 @@
/** Resolved media reference from getSiteSettings() */
export interface MediaReference {
mediaId: string;
alt?: string;
url?: string;
}
export interface BlogSiteIdentitySettings {
title?: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
siteLogo: settings?.logo?.url ? settings.logo : null,
};
}

View File

@@ -0,0 +1,7 @@
{
"extends": "astro/tsconfigs/base",
"compilerOptions": {
"types": ["node"]
},
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
}

View File

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

View File

@@ -0,0 +1,34 @@
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { previewDatabase } from "@emdash-cms/cloudflare";
import { defineConfig, fontProviders } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(),
emdash({
// DO-backed preview database — populated from source site snapshots
database: previewDatabase({ binding: "PREVIEW_DB" }),
}),
],
fonts: [
{
provider: fontProviders.google(),
name: "Inter",
cssVariable: "--font-sans",
weights: [400, 500, 600, 700],
fallbacks: ["sans-serif"],
},
{
provider: fontProviders.google(),
name: "JetBrains Mono",
cssVariable: "--font-mono",
weights: [400, 500],
fallbacks: ["monospace"],
},
],
devToolbar: { enabled: false },
});

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

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

View File

@@ -0,0 +1,31 @@
{
"name": "@emdash-cms/demo-preview",
"version": "0.0.3",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build:all": "pnpm run --filter @emdash-cms/demo-preview... build",
"preview": "astro preview",
"deploy": "pnpm build:all && wrangler deploy",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/cloudflare": "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:"
}
}

47
demos/preview/sign-url.ts Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env -S npx tsx
/**
* Generate a signed preview URL for local testing.
*
* Usage:
* npx tsx sign-url.ts [source] [preview]
*
* Defaults:
* source: http://localhost:4321
* preview: http://localhost:4322
* secret: reads PREVIEW_SECRET from .dev.vars, falls back to "dev-secret"
*/
import { readFileSync } from "node:fs";
const source = process.argv[2] || "http://localhost:4321";
const preview = process.argv[3] || "http://localhost:4322";
let secret = "dev-secret";
try {
const devVars = readFileSync(new URL(".dev.vars", import.meta.url), "utf-8");
const match = devVars.match(/^PREVIEW_SECRET\s*=\s*"?([^"\n]+)"?/m);
if (match) secret = match[1]!;
} catch {
// no .dev.vars, use default
}
const exp = Math.floor(Date.now() / 1000) + 3600;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuffer = await crypto.subtle.sign("HMAC", key, encoder.encode(`${source}:${exp}`));
const sig = Array.from(new Uint8Array(sigBuffer), (b) => b.toString(16).padStart(2, "0")).join("");
const url = new URL(preview);
url.searchParams.set("source", source);
url.searchParams.set("exp", String(exp));
url.searchParams.set("sig", sig);
console.log(url.toString());

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,13 @@
/**
* EmDash Live Content Collections
*
* Defines the _emdash collection that handles all content types from the database.
* Query specific types using getEmDashCollection() and getEmDashEntry().
*/
import { defineLiveCollection } from "astro:content";
import { emdashLoader } from "emdash/runtime";
export const collections = {
_emdash: defineLiveCollection({ loader: emdashLoader() }),
};

View File

@@ -0,0 +1,16 @@
/**
* Preview middleware
*
* Validates signed preview URLs, creates DO-backed sessions,
* populates snapshots, and overrides the request-context DB.
*/
import { createPreviewMiddleware } from "@emdash-cms/cloudflare/db/do";
import { sequence } from "astro:middleware";
const preview = createPreviewMiddleware({
binding: "PREVIEW_DB",
secret: import.meta.env.PREVIEW_SECRET,
});
export const onRequest = sequence(preview);

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