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:
5
infra/blog-demo/.gitignore
vendored
Normal file
5
infra/blog-demo/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
uploads
|
||||
data.db
|
||||
38
infra/blog-demo/AGENTS.md
Normal file
38
infra/blog-demo/AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
This is an EmDash site -- a CMS built on Astro with a full admin UI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
|
||||
npx emdash types # Regenerate TypeScript types from schema
|
||||
npx emdash seed seed/seed.json --validate # Validate seed file
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `astro.config.mjs` | Astro config with `emdash()` integration, database, and storage |
|
||||
| `src/live.config.ts` | EmDash loader registration (boilerplate -- don't modify) |
|
||||
| `seed/seed.json` | Schema definition + demo content (collections, fields, taxonomies, menus, widgets) |
|
||||
| `emdash-env.d.ts` | Generated types for collections (auto-regenerated on dev server start) |
|
||||
| `src/layouts/Base.astro` | Base layout with EmDash wiring (menus, search, page contributions) |
|
||||
| `src/pages/` | Astro pages -- all server-rendered |
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
|
||||
|
||||
- **building-emdash-site** -- Querying content, rendering Portable Text, schema design, seed files, site features (menus, widgets, search, SEO, comments, bylines). Start here.
|
||||
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
|
||||
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
|
||||
|
||||
## Rules
|
||||
|
||||
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
|
||||
- Image fields are objects (`{ src, alt }`), not strings. Use `<Image image={...} />` from `"emdash/ui"`.
|
||||
- `entry.id` is the slug (for URLs). `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
- Always call `Astro.cache.set(cacheHint)` on pages that query content.
|
||||
- Taxonomy names in queries must match the seed's `"name"` field exactly (e.g., `"category"` not `"categories"`).
|
||||
34
infra/blog-demo/CHANGELOG.md
Normal file
34
infra/blog-demo/CHANGELOG.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @emdash-cms/perf-demo-site
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`e2b3c6c`](https://github.com/emdash-cms/emdash/commit/e2b3c6cd930d5fa6fc607a0b26fd796f5b0f98b2), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`e0dc6fb`](https://github.com/emdash-cms/emdash/commit/e0dc6fb8adadc0e048f3f314d62bfa98d9bb48d4), [`c22fb3a`](https://github.com/emdash-cms/emdash/commit/c22fb3a10d445f12cca91620c9258d50695afa44), [`6a4e9b8`](https://github.com/emdash-cms/emdash/commit/6a4e9b8b0fa6064989224a42b14de435f487a76f), [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf), [`22a16ee`](https://github.com/emdash-cms/emdash/commit/22a16eed607a4e81391ecb6c45fe2e59aaca92fe), [`1e2b024`](https://github.com/emdash-cms/emdash/commit/1e2b02486ee0407e4f50b8342ba1a9e7d060e405), [`81662e9`](https://github.com/emdash-cms/emdash/commit/81662e98fcf1ad0ee880d4f1af96271c527d7423), [`2f22f57`](https://github.com/emdash-cms/emdash/commit/2f22f57abadf305cf6d3ce07ee78290178e032d1), [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86), [`a9c29ea`](https://github.com/emdash-cms/emdash/commit/a9c29ea584300f6cf67206bedcb1d39f05ea1c26), [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347), [`d5f7c48`](https://github.com/emdash-cms/emdash/commit/d5f7c481a507868f470361cfd715a5828640d45a), [`8ae227c`](https://github.com/emdash-cms/emdash/commit/8ae227cceade5c9852897c7b56f89e7422ee82a1), [`e2d5d16`](https://github.com/emdash-cms/emdash/commit/e2d5d160acea4444945b1ea79c80ca9ce138965b), [`0d98c62`](https://github.com/emdash-cms/emdash/commit/0d98c620a5f407648f3b7f3cbd30b642c74be607), [`64bf5b9`](https://github.com/emdash-cms/emdash/commit/64bf5b98125ca18ec26f7e0e65a71fcbe71fd44f), [`e81aa0f`](https://github.com/emdash-cms/emdash/commit/e81aa0f717be11bacdff30ed9bbc454824268555), [`a838000`](https://github.com/emdash-cms/emdash/commit/a83800068678daf6391e02bba8acf27ff4db0e19), [`0041d76`](https://github.com/emdash-cms/emdash/commit/0041d7699b32b77b4cd2ecd77b97340f0dd3abce), [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743), [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e), [`5b6f059`](https://github.com/emdash-cms/emdash/commit/5b6f059d06175ae0cb740d1ba32867d1ec6b2249), [`a86ff80`](https://github.com/emdash-cms/emdash/commit/a86ff80836fed175508ff06f744c7ad6b805627c), [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94), [`eb6dbd0`](https://github.com/emdash-cms/emdash/commit/eb6dbd056717fd076a8b5fa807d91516a00f5f2f)]:
|
||||
- emdash@0.9.0
|
||||
- @emdash-cms/plugin-forms@0.2.0
|
||||
- @emdash-cms/cloudflare@0.9.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`37ada52`](https://github.com/emdash-cms/emdash/commit/37ada52a62e94f4f0581f4356ba55dc978863f49), [`0557b62`](https://github.com/emdash-cms/emdash/commit/0557b62ec646e49eeb5e28686d50b4e8746338be), [`5a581d9`](https://github.com/emdash-cms/emdash/commit/5a581d966cc1da72637a76ad42a7ac3b81ec59c3), [`0ecd3b4`](https://github.com/emdash-cms/emdash/commit/0ecd3b4901eb721825b36eb4812506032e43da14), [`3138432`](https://github.com/emdash-cms/emdash/commit/31384322537070db8c35e4f93f4ffe8225d784d6), [`70924cd`](https://github.com/emdash-cms/emdash/commit/70924cd19b4227b3a1ecfad6618f1a80530a378b), [`1f0f6f2`](https://github.com/emdash-cms/emdash/commit/1f0f6f2507d026f2b5c60c254432bfc327b3474f), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`e402890`](https://github.com/emdash-cms/emdash/commit/e402890fcd8647fdfe847bb34aa9f9e7094473dd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`f5658f0`](https://github.com/emdash-cms/emdash/commit/f5658f052f7294039f7ea8c5eb8b49af263beb0d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`b6cb2e6`](https://github.com/emdash-cms/emdash/commit/b6cb2e6c7001d37a0558e22953eba41013457528), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`cf1edae`](https://github.com/emdash-cms/emdash/commit/cf1edae6ac3e5cd8c72fd43a09bb80bae5cc8031), [`b352e88`](https://github.com/emdash-cms/emdash/commit/b352e881fedb7f6fdc35f9d75402f67caba7f154), [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd), [`da3d065`](https://github.com/emdash-cms/emdash/commit/da3d0656a4431365176cca65dc2bedf5eca19ce3), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`47978b5`](https://github.com/emdash-cms/emdash/commit/47978b5e1b69b671d2ea5c08ee0bbf4c72d1594d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd)]:
|
||||
- emdash@1.0.0
|
||||
- @emdash-cms/cloudflare@1.0.0
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8ebdf1a`](https://github.com/emdash-cms/emdash/commit/8ebdf1af65764cc4b72624e7758c4a666817aade), [`7186961`](https://github.com/emdash-cms/emdash/commit/7186961d3cbf706c1248e9e40b14b1a545ce8586), [`e9ecec2`](https://github.com/emdash-cms/emdash/commit/e9ecec2d2dfb20ab4c413fb593a09a9f6d0fb27e), [`e3e18aa`](https://github.com/emdash-cms/emdash/commit/e3e18aae92d31cf22efd11a0ba06110de24a076a), [`fae63bd`](https://github.com/emdash-cms/emdash/commit/fae63bdae8ff798a420379c36d3d05e54ea3628a), [`30d8fe0`](https://github.com/emdash-cms/emdash/commit/30d8fe00025e058c71c8bfcd296946bb2042c4a7), [`d4a95bf`](https://github.com/emdash-cms/emdash/commit/d4a95bf313855e97108dfec4de3ab35f1a85f8ba), [`63509e1`](https://github.com/emdash-cms/emdash/commit/63509e18f24f3ede3254065ba69d0177b1858555), [`a31db7d`](https://github.com/emdash-cms/emdash/commit/a31db7dcc6d9ddb09328eec815d255a4976ce3b8), [`adb118c`](https://github.com/emdash-cms/emdash/commit/adb118c99d867be7b17714798e1e565ccdf096e4), [`080a4f1`](https://github.com/emdash-cms/emdash/commit/080a4f1efdd793cddd49767d8b18cd53162f39e3), [`81fe93b`](https://github.com/emdash-cms/emdash/commit/81fe93bc675581ddd0161eaabbe7a3471ec76529), [`c26442b`](https://github.com/emdash-cms/emdash/commit/c26442be9887f1e3d3df37db5ccda6b260820a77)]:
|
||||
- emdash@0.7.0
|
||||
- @emdash-cms/cloudflare@0.7.0
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`ada4ac7`](https://github.com/emdash-cms/emdash/commit/ada4ac7105f72a96eaf4ce3d884d705d8aba0119), [`f279320`](https://github.com/emdash-cms/emdash/commit/f279320ef49c68662c8936db15e21f46cb57e82b), [`7f75193`](https://github.com/emdash-cms/emdash/commit/7f75193df49967c871acdf47a22f0e48d2e98986), [`cfd01f3`](https://github.com/emdash-cms/emdash/commit/cfd01f3bd484b38549a5a164ad006279a2024788), [`38d637b`](https://github.com/emdash-cms/emdash/commit/38d637b520f8596758939ec08a7b534bb9550967), [`31d2f4e`](https://github.com/emdash-cms/emdash/commit/31d2f4edd5e84391e23f2eb6ff833e2fd4e51077), [`445b3bf`](https://github.com/emdash-cms/emdash/commit/445b3bfecf1f4cdc109be865685eb6ae6e0c06e6), [`943d540`](https://github.com/emdash-cms/emdash/commit/943d54060eb6675dda643b09f7cdb80bbbe5d566), [`2cb3165`](https://github.com/emdash-cms/emdash/commit/2cb31658037bc2b9ebfd3c5b82e4fb709b4a1fad), [`1859347`](https://github.com/emdash-cms/emdash/commit/18593475bb8e30ce1aab55d72903d02dbf3fd0cb), [`14c923b`](https://github.com/emdash-cms/emdash/commit/14c923b5eaf23f6e601cd2559ce9fc3af2f40822), [`c5ef0f5`](https://github.com/emdash-cms/emdash/commit/c5ef0f5befda129e4040822ee341f8cd8bb5acaf), [`f839381`](https://github.com/emdash-cms/emdash/commit/f8393819e74b31c269ba6c5088eab1f40b438c62), [`002d0ac`](https://github.com/emdash-cms/emdash/commit/002d0accd87fc0b6983a3a45fd11227398837366), [`134f776`](https://github.com/emdash-cms/emdash/commit/134f77673e59ea597b271c2bef74fd3eb5c38e0e), [`0a61ef4`](https://github.com/emdash-cms/emdash/commit/0a61ef412ef8d2643fa847caeddbe8b8933d3fc7), [`6d41fe1`](https://github.com/emdash-cms/emdash/commit/6d41fe16539d09c53916b4ca41c515a29f8e0d4f), [`b158e40`](https://github.com/emdash-cms/emdash/commit/b158e40de596e8ca3cb056495276ec97403c24d9), [`f97d6ab`](https://github.com/emdash-cms/emdash/commit/f97d6ab0f1995fe86862aeb20de65d0ee774699f), [`e67b940`](https://github.com/emdash-cms/emdash/commit/e67b94056c21c716eada0fff7350b8592c6a3c68), [`0896ec8`](https://github.com/emdash-cms/emdash/commit/0896ec81065da7fa9b93053d366500805602c8fe), [`629fe1d`](https://github.com/emdash-cms/emdash/commit/629fe1dd3094a0178c57529a455a2be805b08ad0), [`f52154d`](https://github.com/emdash-cms/emdash/commit/f52154da8afb838b1af6deccf33b5a261257ec7c), [`8221c2a`](https://github.com/emdash-cms/emdash/commit/8221c2a3a37353b550f1c2c4a188bc4e2725b914), [`8fb93eb`](https://github.com/emdash-cms/emdash/commit/8fb93eb045eb529eafd83e451ec673106f5bdb3c), [`6d7f288`](https://github.com/emdash-cms/emdash/commit/6d7f288d812b554988742c36ef7a74be67386e6d), [`4ffa141`](https://github.com/emdash-cms/emdash/commit/4ffa141c00ec7b9785bbb86f9292055e46b22a61), [`04e6cca`](https://github.com/emdash-cms/emdash/commit/04e6ccaa939f184edf4129eea0edf8ac5185d018), [`9295cc1`](https://github.com/emdash-cms/emdash/commit/9295cc199f72c9b9adff236e4a72ba412604493f)]:
|
||||
- emdash@0.6.0
|
||||
- @emdash-cms/cloudflare@0.6.0
|
||||
44
infra/blog-demo/astro.config.mjs
Normal file
44
infra/blog-demo/astro.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import react from "@astrojs/react";
|
||||
import { d1, r2, sandbox } 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(),
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
plugins: [formsPlugin()],
|
||||
sandboxed: [webhookNotifierPlugin()],
|
||||
sandboxRunner: sandbox(),
|
||||
marketplace: "https://marketplace.emdashcms.com",
|
||||
}),
|
||||
],
|
||||
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
infra/blog-demo/emdash-env.d.ts
vendored
Normal file
39
infra/blog-demo/emdash-env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
content?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
|
||||
content?: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
37
infra/blog-demo/package.json
Normal file
37
infra/blog-demo/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@emdash-cms/perf-demo-site",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "astro build && wrangler deploy",
|
||||
"typecheck": "astro check",
|
||||
"bootstrap": "emdash init && emdash seed",
|
||||
"seed": "emdash seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "catalog:",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@emdash-cms/cloudflare": "workspace:*",
|
||||
"@emdash-cms/plugin-forms": "workspace:*",
|
||||
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
|
||||
"astro": "catalog:",
|
||||
"emdash": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"wrangler": "catalog:"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"workerd"
|
||||
]
|
||||
}
|
||||
}
|
||||
778
infra/blog-demo/seed/seed.json
Normal file
778
infra/blog-demo/seed/seed.json
Normal file
@@ -0,0 +1,778 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "Blog Starter",
|
||||
"description": "A blog with posts and pages",
|
||||
"author": "EmDash"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "My Blog",
|
||||
"tagline": "Thoughts on building for the web"
|
||||
},
|
||||
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"supports": ["drafts", "revisions", "search", "seo"],
|
||||
"commentsEnabled": true,
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "featured_image",
|
||||
"label": "Featured Image",
|
||||
"type": "image"
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText",
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "excerpt",
|
||||
"label": "Excerpt",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"supports": ["drafts", "revisions", "search"],
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText",
|
||||
"searchable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "development", "label": "Development" },
|
||||
{ "slug": "design", "label": "Design" },
|
||||
{ "slug": "notes", "label": "Notes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"label": "Tags",
|
||||
"labelSingular": "Tag",
|
||||
"hierarchical": false,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "webdev", "label": "Web Development" },
|
||||
{ "slug": "opinion", "label": "Opinion" },
|
||||
{ "slug": "tools", "label": "Tools" },
|
||||
{ "slug": "creativity", "label": "Creativity" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"bylines": [
|
||||
{
|
||||
"id": "byline-editorial",
|
||||
"slug": "emdash-editorial",
|
||||
"displayName": "EmDash Editorial"
|
||||
},
|
||||
{
|
||||
"id": "byline-guest",
|
||||
"slug": "guest-contributor",
|
||||
"displayName": "Guest Contributor",
|
||||
"isGuest": true
|
||||
}
|
||||
],
|
||||
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "About", "url": "/pages/about" },
|
||||
{ "type": "custom", "label": "Posts", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"widgetAreas": [
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Sidebar",
|
||||
"description": "Widget area displayed on single post pages",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:search",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:categories",
|
||||
"title": "Categories"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:tags",
|
||||
"title": "Tags"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:recent-posts",
|
||||
"title": "Recent Posts",
|
||||
"settings": {
|
||||
"count": 5,
|
||||
"showDate": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:archives",
|
||||
"title": "Archives",
|
||||
"settings": {
|
||||
"type": "monthly",
|
||||
"limit": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "footer",
|
||||
"label": "Footer",
|
||||
"description": "Widget area displayed in the site footer",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A blog about software, design, and the occasional stray thought."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"sections": [
|
||||
{
|
||||
"slug": "newsletter-signup",
|
||||
"title": "Newsletter Signup",
|
||||
"description": "A call-to-action block for newsletter subscriptions",
|
||||
"keywords": ["newsletter", "subscribe", "email", "cta"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Stay in the loop" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Get notified when new posts are published. No spam, unsubscribe anytime."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "about-author",
|
||||
"title": "About the Author",
|
||||
"description": "Brief author bio for use in posts or pages",
|
||||
"keywords": ["author", "bio", "about"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "about",
|
||||
"slug": "about",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Built with Astro and EmDash. The source is open if you want to see how it works."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"id": "post-1",
|
||||
"slug": "building-for-the-long-term",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Building for the Long Term",
|
||||
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a monitor in a dark room",
|
||||
"filename": "building-long-term.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "What survives" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [
|
||||
{ "byline": "byline-editorial" },
|
||||
{ "byline": "byline-guest", "roleLabel": "Guest essay" }
|
||||
],
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-2",
|
||||
"slug": "the-case-for-static",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "The Case for Static",
|
||||
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
|
||||
"alt": "Laptop and coffee on a wooden table",
|
||||
"filename": "case-for-static.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The performance argument" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [{ "byline": "byline-editorial" }],
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["webdev", "opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-3",
|
||||
"slug": "learning-in-public",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Learning in Public",
|
||||
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
|
||||
"alt": "Notebook and pen on a desk",
|
||||
"filename": "learning-in-public.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The fear of being wrong" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-4",
|
||||
"slug": "small-tools-big-impact",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Small Tools, Big Impact",
|
||||
"excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
|
||||
"alt": "Wrenches and hand tools hanging on a workshop wall",
|
||||
"filename": "small-tools.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The Unix philosophy, revisited" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["tools"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-5",
|
||||
"slug": "designing-with-constraints",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Designing with Constraints",
|
||||
"excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
|
||||
"alt": "Pencils and design tools on a desk",
|
||||
"filename": "designing-with-constraints.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Embracing the box" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["design"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-6",
|
||||
"slug": "a-weekend-with-a-side-project",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "A Weekend with a Side Project",
|
||||
"excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a screen with a dark theme",
|
||||
"filename": "weekend-side-project.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Why side projects matter" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-7",
|
||||
"slug": "notes-on-simplicity",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Notes on Simplicity",
|
||||
"excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
|
||||
"alt": "Geometric pattern carved into white paper",
|
||||
"filename": "notes-on-simplicity.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Removing as a feature" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-draft",
|
||||
"slug": "work-in-progress",
|
||||
"status": "draft",
|
||||
"data": {
|
||||
"title": "Work in Progress",
|
||||
"excerpt": "This post is still being written.",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is a draft post that won't appear in the public listing."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
279
infra/blog-demo/src/components/PostCard.astro
Normal file
279
infra/blog-demo/src/components/PostCard.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
import type { MediaValue, ContentBylineCredit } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
featuredImage?: MediaValue | string;
|
||||
href: string;
|
||||
date?: Date;
|
||||
readingTime?: number;
|
||||
tags?: Array<{ slug: string; label: string }>;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
href,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
bylines,
|
||||
} = Astro.props;
|
||||
|
||||
const formattedDate = date
|
||||
? date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
---
|
||||
|
||||
<article class="post-card">
|
||||
<a href={href} class="card-link">
|
||||
{
|
||||
featuredImage ? (
|
||||
<div class="card-image">
|
||||
<Image image={featuredImage} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="card-placeholder" />
|
||||
)
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
{
|
||||
bylines && bylines.length > 0 && (
|
||||
<>
|
||||
<div class="card-bylines">
|
||||
{bylines.slice(0, 1).map((credit) => (
|
||||
<span class="card-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="card-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="card-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{bylines.length > 1 && (
|
||||
<span
|
||||
class="byline-more"
|
||||
data-tooltip={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
title={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
tabindex="0"
|
||||
>
|
||||
+{bylines.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(formattedDate || readingTime) && <span class="meta-dot" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{formattedDate && <time>{formattedDate}</time>}
|
||||
{formattedDate && readingTime && <span class="meta-dot" />}
|
||||
{readingTime && <span>{readingTime} min</span>}
|
||||
</div>
|
||||
<h2 class="card-title">{title}</h2>
|
||||
{excerpt && <p class="card-excerpt">{excerpt}</p>}
|
||||
</div>
|
||||
</a>
|
||||
{
|
||||
tags && tags.length > 0 && (
|
||||
<div class="card-tags">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="card-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .card-image img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-2);
|
||||
row-gap: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.card-meta time,
|
||||
.card-meta span:not(.meta-dot) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-link:hover .card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.card-excerpt {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.card-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.card-byline-avatar {
|
||||
width: var(--avatar-size-xs);
|
||||
height: var(--avatar-size-xs);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
position: relative;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
cursor: default;
|
||||
border-radius: var(--radius);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.byline-more:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.byline-more[data-tooltip]:hover::after,
|
||||
.byline-more[data-tooltip]:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
45
infra/blog-demo/src/components/TagList.astro
Normal file
45
infra/blog-demo/src/components/TagList.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
tags: Array<{ slug: string; label: string }>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tags, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{tags.length > 0 && (
|
||||
<ul class:list={["tag-list", className]}>
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
1010
infra/blog-demo/src/layouts/Base.astro
Normal file
1010
infra/blog-demo/src/layouts/Base.astro
Normal file
File diff suppressed because it is too large
Load Diff
13
infra/blog-demo/src/live.config.ts
Normal file
13
infra/blog-demo/src/live.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* EmDash Live Content Collections
|
||||
*
|
||||
* Defines the _emdash collection that handles all content types from the database.
|
||||
* Query specific types using getEmDashCollection() and getEmDashEntry().
|
||||
*/
|
||||
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
33
infra/blog-demo/src/pages/404.astro
Normal file
33
infra/blog-demo/src/pages/404.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Page not found">
|
||||
<div class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go back home</a>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: var(--spacing-24) var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-size-5xl);
|
||||
margin-bottom: var(--spacing-2);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
129
infra/blog-demo/src/pages/category/[slug].astro
Normal file
129
infra/blog-demo/src/pages/category/[slug].astro
Normal 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>
|
||||
463
infra/blog-demo/src/pages/index.astro
Normal file
463
infra/blog-demo/src/pages/index.astro
Normal 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(--spacing-1) 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);
|
||||
}
|
||||
|
||||
.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>
|
||||
108
infra/blog-demo/src/pages/pages/[slug].astro
Normal file
108
infra/blog-demo/src/pages/pages/[slug].astro
Normal 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>
|
||||
970
infra/blog-demo/src/pages/posts/[slug].astro
Normal file
970
infra/blog-demo/src/pages/posts/[slug].astro
Normal 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;
|
||||
}
|
||||
|
||||
.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(--spacing-1) 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);
|
||||
}
|
||||
|
||||
.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);
|
||||
grid-template-rows: repeat(5, auto);
|
||||
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>
|
||||
272
infra/blog-demo/src/pages/posts/index.astro
Normal file
272
infra/blog-demo/src/pages/posts/index.astro
Normal 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-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);
|
||||
}
|
||||
|
||||
.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>
|
||||
70
infra/blog-demo/src/pages/rss.xml.ts
Normal file
70
infra/blog-demo/src/pages/rss.xml.ts
Normal 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, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
[/'/g, "'"],
|
||||
] as const;
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
let result = str;
|
||||
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
182
infra/blog-demo/src/pages/search.astro
Normal file
182
infra/blog-demo/src/pages/search.astro
Normal 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>
|
||||
131
infra/blog-demo/src/pages/tag/[slug].astro
Normal file
131
infra/blog-demo/src/pages/tag/[slug].astro
Normal 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>
|
||||
108
infra/blog-demo/src/styles/theme.css
Normal file
108
infra/blog-demo/src/styles/theme.css
Normal 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 h2–h6, 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
|
||||
*/
|
||||
}
|
||||
66
infra/blog-demo/src/utils/reading-time.ts
Normal file
66
infra/blog-demo/src/utils/reading-time.ts
Normal 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`;
|
||||
}
|
||||
24
infra/blog-demo/src/utils/site-identity.ts
Normal file
24
infra/blog-demo/src/utils/site-identity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5
infra/blog-demo/src/worker.ts
Normal file
5
infra/blog-demo/src/worker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import handler from "@astrojs/cloudflare/entrypoints/server";
|
||||
|
||||
export { PluginBridge } from "@emdash-cms/cloudflare/sandbox";
|
||||
|
||||
export default handler;
|
||||
7
infra/blog-demo/tsconfig.json
Normal file
7
infra/blog-demo/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||
}
|
||||
40
infra/blog-demo/wrangler.jsonc
Normal file
40
infra/blog-demo/wrangler.jsonc
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "emdash-demo-blog",
|
||||
"main": "./src/worker.ts",
|
||||
"account_id": "1f74638c495bc9f0330ce5c8e64c1b6b",
|
||||
"compatibility_date": "2026-02-24",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"placement": { "mode": "smart" },
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "blog-demo.emdashcms.com",
|
||||
"zone_name": "emdashcms.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash-demo-blog",
|
||||
"database_id": "9361329b-a0f6-4c59-a184-74747a705e54",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "emdash-demo-media",
|
||||
},
|
||||
],
|
||||
"worker_loaders": [
|
||||
{
|
||||
"binding": "LOADER",
|
||||
},
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "SESSION",
|
||||
"id": "045dd0600977420992277da020ae2df3",
|
||||
},
|
||||
],
|
||||
}
|
||||
5
infra/cache-demo/.gitignore
vendored
Normal file
5
infra/cache-demo/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.astro
|
||||
uploads
|
||||
data.db
|
||||
38
infra/cache-demo/AGENTS.md
Normal file
38
infra/cache-demo/AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
||||
This is an EmDash site -- a CMS built on Astro with a full admin UI.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npx emdash dev # Start dev server (runs migrations, seeds, generates types)
|
||||
npx emdash types # Regenerate TypeScript types from schema
|
||||
npx emdash seed seed/seed.json --validate # Validate seed file
|
||||
```
|
||||
|
||||
The admin UI is at `http://localhost:4321/_emdash/admin`.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Purpose |
|
||||
| ------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| `astro.config.mjs` | Astro config with `emdash()` integration, database, and storage |
|
||||
| `src/live.config.ts` | EmDash loader registration (boilerplate -- don't modify) |
|
||||
| `seed/seed.json` | Schema definition + demo content (collections, fields, taxonomies, menus, widgets) |
|
||||
| `emdash-env.d.ts` | Generated types for collections (auto-regenerated on dev server start) |
|
||||
| `src/layouts/Base.astro` | Base layout with EmDash wiring (menus, search, page contributions) |
|
||||
| `src/pages/` | Astro pages -- all server-rendered |
|
||||
|
||||
## Skills
|
||||
|
||||
Agent skills are in `.agents/skills/`. Load them when working on specific tasks:
|
||||
|
||||
- **building-emdash-site** -- Querying content, rendering Portable Text, schema design, seed files, site features (menus, widgets, search, SEO, comments, bylines). Start here.
|
||||
- **creating-plugins** -- Building EmDash plugins with hooks, storage, admin UI, API routes, and Portable Text block types.
|
||||
- **emdash-cli** -- CLI commands for content management, seeding, type generation, and visual editing flow.
|
||||
|
||||
## Rules
|
||||
|
||||
- All content pages must be server-rendered (`output: "server"`). No `getStaticPaths()` for CMS content.
|
||||
- Image fields are objects (`{ src, alt }`), not strings. Use `<Image image={...} />` from `"emdash/ui"`.
|
||||
- `entry.id` is the slug (for URLs). `entry.data.id` is the database ULID (for API calls like `getEntryTerms`).
|
||||
- Always call `Astro.cache.set(cacheHint)` on pages that query content.
|
||||
- Taxonomy names in queries must match the seed's `"name"` field exactly (e.g., `"category"` not `"categories"`).
|
||||
34
infra/cache-demo/CHANGELOG.md
Normal file
34
infra/cache-demo/CHANGELOG.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# @emdash-cms/cache-demo-site
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`e2b3c6c`](https://github.com/emdash-cms/emdash/commit/e2b3c6cd930d5fa6fc607a0b26fd796f5b0f98b2), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`e0dc6fb`](https://github.com/emdash-cms/emdash/commit/e0dc6fb8adadc0e048f3f314d62bfa98d9bb48d4), [`c22fb3a`](https://github.com/emdash-cms/emdash/commit/c22fb3a10d445f12cca91620c9258d50695afa44), [`6a4e9b8`](https://github.com/emdash-cms/emdash/commit/6a4e9b8b0fa6064989224a42b14de435f487a76f), [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf), [`22a16ee`](https://github.com/emdash-cms/emdash/commit/22a16eed607a4e81391ecb6c45fe2e59aaca92fe), [`1e2b024`](https://github.com/emdash-cms/emdash/commit/1e2b02486ee0407e4f50b8342ba1a9e7d060e405), [`81662e9`](https://github.com/emdash-cms/emdash/commit/81662e98fcf1ad0ee880d4f1af96271c527d7423), [`2f22f57`](https://github.com/emdash-cms/emdash/commit/2f22f57abadf305cf6d3ce07ee78290178e032d1), [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86), [`a9c29ea`](https://github.com/emdash-cms/emdash/commit/a9c29ea584300f6cf67206bedcb1d39f05ea1c26), [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347), [`d5f7c48`](https://github.com/emdash-cms/emdash/commit/d5f7c481a507868f470361cfd715a5828640d45a), [`8ae227c`](https://github.com/emdash-cms/emdash/commit/8ae227cceade5c9852897c7b56f89e7422ee82a1), [`e2d5d16`](https://github.com/emdash-cms/emdash/commit/e2d5d160acea4444945b1ea79c80ca9ce138965b), [`0d98c62`](https://github.com/emdash-cms/emdash/commit/0d98c620a5f407648f3b7f3cbd30b642c74be607), [`64bf5b9`](https://github.com/emdash-cms/emdash/commit/64bf5b98125ca18ec26f7e0e65a71fcbe71fd44f), [`e81aa0f`](https://github.com/emdash-cms/emdash/commit/e81aa0f717be11bacdff30ed9bbc454824268555), [`a838000`](https://github.com/emdash-cms/emdash/commit/a83800068678daf6391e02bba8acf27ff4db0e19), [`0041d76`](https://github.com/emdash-cms/emdash/commit/0041d7699b32b77b4cd2ecd77b97340f0dd3abce), [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743), [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e), [`5b6f059`](https://github.com/emdash-cms/emdash/commit/5b6f059d06175ae0cb740d1ba32867d1ec6b2249), [`a86ff80`](https://github.com/emdash-cms/emdash/commit/a86ff80836fed175508ff06f744c7ad6b805627c), [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94), [`eb6dbd0`](https://github.com/emdash-cms/emdash/commit/eb6dbd056717fd076a8b5fa807d91516a00f5f2f)]:
|
||||
- emdash@0.9.0
|
||||
- @emdash-cms/plugin-forms@0.2.0
|
||||
- @emdash-cms/cloudflare@0.9.0
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`37ada52`](https://github.com/emdash-cms/emdash/commit/37ada52a62e94f4f0581f4356ba55dc978863f49), [`0557b62`](https://github.com/emdash-cms/emdash/commit/0557b62ec646e49eeb5e28686d50b4e8746338be), [`5a581d9`](https://github.com/emdash-cms/emdash/commit/5a581d966cc1da72637a76ad42a7ac3b81ec59c3), [`0ecd3b4`](https://github.com/emdash-cms/emdash/commit/0ecd3b4901eb721825b36eb4812506032e43da14), [`3138432`](https://github.com/emdash-cms/emdash/commit/31384322537070db8c35e4f93f4ffe8225d784d6), [`70924cd`](https://github.com/emdash-cms/emdash/commit/70924cd19b4227b3a1ecfad6618f1a80530a378b), [`1f0f6f2`](https://github.com/emdash-cms/emdash/commit/1f0f6f2507d026f2b5c60c254432bfc327b3474f), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`e402890`](https://github.com/emdash-cms/emdash/commit/e402890fcd8647fdfe847bb34aa9f9e7094473dd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`f5658f0`](https://github.com/emdash-cms/emdash/commit/f5658f052f7294039f7ea8c5eb8b49af263beb0d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`b6cb2e6`](https://github.com/emdash-cms/emdash/commit/b6cb2e6c7001d37a0558e22953eba41013457528), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`cf1edae`](https://github.com/emdash-cms/emdash/commit/cf1edae6ac3e5cd8c72fd43a09bb80bae5cc8031), [`b352e88`](https://github.com/emdash-cms/emdash/commit/b352e881fedb7f6fdc35f9d75402f67caba7f154), [`31333dc`](https://github.com/emdash-cms/emdash/commit/31333dc593e2b9128113e4e923455209f11853fd), [`da3d065`](https://github.com/emdash-cms/emdash/commit/da3d0656a4431365176cca65dc2bedf5eca19ce3), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd), [`47978b5`](https://github.com/emdash-cms/emdash/commit/47978b5e1b69b671d2ea5c08ee0bbf4c72d1594d), [`3eca9d5`](https://github.com/emdash-cms/emdash/commit/3eca9d54be03a803d35e112f4114f85f53a23acd)]:
|
||||
- emdash@1.0.0
|
||||
- @emdash-cms/cloudflare@1.0.0
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`8ebdf1a`](https://github.com/emdash-cms/emdash/commit/8ebdf1af65764cc4b72624e7758c4a666817aade), [`7186961`](https://github.com/emdash-cms/emdash/commit/7186961d3cbf706c1248e9e40b14b1a545ce8586), [`e9ecec2`](https://github.com/emdash-cms/emdash/commit/e9ecec2d2dfb20ab4c413fb593a09a9f6d0fb27e), [`e3e18aa`](https://github.com/emdash-cms/emdash/commit/e3e18aae92d31cf22efd11a0ba06110de24a076a), [`fae63bd`](https://github.com/emdash-cms/emdash/commit/fae63bdae8ff798a420379c36d3d05e54ea3628a), [`30d8fe0`](https://github.com/emdash-cms/emdash/commit/30d8fe00025e058c71c8bfcd296946bb2042c4a7), [`d4a95bf`](https://github.com/emdash-cms/emdash/commit/d4a95bf313855e97108dfec4de3ab35f1a85f8ba), [`63509e1`](https://github.com/emdash-cms/emdash/commit/63509e18f24f3ede3254065ba69d0177b1858555), [`a31db7d`](https://github.com/emdash-cms/emdash/commit/a31db7dcc6d9ddb09328eec815d255a4976ce3b8), [`adb118c`](https://github.com/emdash-cms/emdash/commit/adb118c99d867be7b17714798e1e565ccdf096e4), [`080a4f1`](https://github.com/emdash-cms/emdash/commit/080a4f1efdd793cddd49767d8b18cd53162f39e3), [`81fe93b`](https://github.com/emdash-cms/emdash/commit/81fe93bc675581ddd0161eaabbe7a3471ec76529), [`c26442b`](https://github.com/emdash-cms/emdash/commit/c26442be9887f1e3d3df37db5ccda6b260820a77)]:
|
||||
- emdash@0.7.0
|
||||
- @emdash-cms/cloudflare@0.7.0
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [[`ada4ac7`](https://github.com/emdash-cms/emdash/commit/ada4ac7105f72a96eaf4ce3d884d705d8aba0119), [`f279320`](https://github.com/emdash-cms/emdash/commit/f279320ef49c68662c8936db15e21f46cb57e82b), [`7f75193`](https://github.com/emdash-cms/emdash/commit/7f75193df49967c871acdf47a22f0e48d2e98986), [`cfd01f3`](https://github.com/emdash-cms/emdash/commit/cfd01f3bd484b38549a5a164ad006279a2024788), [`38d637b`](https://github.com/emdash-cms/emdash/commit/38d637b520f8596758939ec08a7b534bb9550967), [`31d2f4e`](https://github.com/emdash-cms/emdash/commit/31d2f4edd5e84391e23f2eb6ff833e2fd4e51077), [`445b3bf`](https://github.com/emdash-cms/emdash/commit/445b3bfecf1f4cdc109be865685eb6ae6e0c06e6), [`943d540`](https://github.com/emdash-cms/emdash/commit/943d54060eb6675dda643b09f7cdb80bbbe5d566), [`2cb3165`](https://github.com/emdash-cms/emdash/commit/2cb31658037bc2b9ebfd3c5b82e4fb709b4a1fad), [`1859347`](https://github.com/emdash-cms/emdash/commit/18593475bb8e30ce1aab55d72903d02dbf3fd0cb), [`14c923b`](https://github.com/emdash-cms/emdash/commit/14c923b5eaf23f6e601cd2559ce9fc3af2f40822), [`c5ef0f5`](https://github.com/emdash-cms/emdash/commit/c5ef0f5befda129e4040822ee341f8cd8bb5acaf), [`f839381`](https://github.com/emdash-cms/emdash/commit/f8393819e74b31c269ba6c5088eab1f40b438c62), [`002d0ac`](https://github.com/emdash-cms/emdash/commit/002d0accd87fc0b6983a3a45fd11227398837366), [`134f776`](https://github.com/emdash-cms/emdash/commit/134f77673e59ea597b271c2bef74fd3eb5c38e0e), [`0a61ef4`](https://github.com/emdash-cms/emdash/commit/0a61ef412ef8d2643fa847caeddbe8b8933d3fc7), [`6d41fe1`](https://github.com/emdash-cms/emdash/commit/6d41fe16539d09c53916b4ca41c515a29f8e0d4f), [`b158e40`](https://github.com/emdash-cms/emdash/commit/b158e40de596e8ca3cb056495276ec97403c24d9), [`f97d6ab`](https://github.com/emdash-cms/emdash/commit/f97d6ab0f1995fe86862aeb20de65d0ee774699f), [`e67b940`](https://github.com/emdash-cms/emdash/commit/e67b94056c21c716eada0fff7350b8592c6a3c68), [`0896ec8`](https://github.com/emdash-cms/emdash/commit/0896ec81065da7fa9b93053d366500805602c8fe), [`629fe1d`](https://github.com/emdash-cms/emdash/commit/629fe1dd3094a0178c57529a455a2be805b08ad0), [`f52154d`](https://github.com/emdash-cms/emdash/commit/f52154da8afb838b1af6deccf33b5a261257ec7c), [`8221c2a`](https://github.com/emdash-cms/emdash/commit/8221c2a3a37353b550f1c2c4a188bc4e2725b914), [`8fb93eb`](https://github.com/emdash-cms/emdash/commit/8fb93eb045eb529eafd83e451ec673106f5bdb3c), [`6d7f288`](https://github.com/emdash-cms/emdash/commit/6d7f288d812b554988742c36ef7a74be67386e6d), [`4ffa141`](https://github.com/emdash-cms/emdash/commit/4ffa141c00ec7b9785bbb86f9292055e46b22a61), [`04e6cca`](https://github.com/emdash-cms/emdash/commit/04e6ccaa939f184edf4129eea0edf8ac5185d018), [`9295cc1`](https://github.com/emdash-cms/emdash/commit/9295cc199f72c9b9adff236e4a72ba412604493f)]:
|
||||
- emdash@0.6.0
|
||||
- @emdash-cms/cloudflare@0.6.0
|
||||
50
infra/cache-demo/astro.config.mjs
Normal file
50
infra/cache-demo/astro.config.mjs
Normal file
@@ -0,0 +1,50 @@
|
||||
import cloudflare from "@astrojs/cloudflare";
|
||||
import { cacheCloudflare } from "@astrojs/cloudflare/cache";
|
||||
import react from "@astrojs/react";
|
||||
import { d1, r2, sandbox } 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(),
|
||||
experimental: {
|
||||
cache: {
|
||||
provider: cacheCloudflare(),
|
||||
},
|
||||
},
|
||||
image: {
|
||||
layout: "constrained",
|
||||
responsiveStyles: true,
|
||||
},
|
||||
integrations: [
|
||||
react(),
|
||||
emdash({
|
||||
database: d1({ binding: "DB", session: "auto" }),
|
||||
storage: r2({ binding: "MEDIA" }),
|
||||
plugins: [formsPlugin()],
|
||||
sandboxed: [webhookNotifierPlugin()],
|
||||
sandboxRunner: sandbox(),
|
||||
marketplace: "https://marketplace.emdashcms.com",
|
||||
}),
|
||||
],
|
||||
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
infra/cache-demo/emdash-env.d.ts
vendored
Normal file
39
infra/cache-demo/emdash-env.d.ts
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
// Generated by EmDash on dev server start
|
||||
// Do not edit manually
|
||||
|
||||
/// <reference types="emdash/locals" />
|
||||
|
||||
import type { ContentBylineCredit, PortableTextBlock } from "emdash";
|
||||
|
||||
export interface Page {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
content?: PortableTextBlock[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: string;
|
||||
slug: string | null;
|
||||
status: string;
|
||||
title: string;
|
||||
featured_image?: { id: string; src?: string; alt?: string; width?: number; height?: number };
|
||||
content?: PortableTextBlock[];
|
||||
excerpt?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt: Date | null;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
declare module "emdash" {
|
||||
interface EmDashCollections {
|
||||
pages: Page;
|
||||
posts: Post;
|
||||
}
|
||||
}
|
||||
37
infra/cache-demo/package.json
Normal file
37
infra/cache-demo/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@emdash-cms/cache-demo-site",
|
||||
"version": "0.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"deploy": "astro build && wrangler deploy",
|
||||
"typecheck": "astro check",
|
||||
"bootstrap": "emdash init && emdash seed",
|
||||
"seed": "emdash seed"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "https://pkg.pr.new/@astrojs/cloudflare@94d342d",
|
||||
"@astrojs/react": "catalog:",
|
||||
"@emdash-cms/cloudflare": "workspace:*",
|
||||
"@emdash-cms/plugin-forms": "workspace:*",
|
||||
"@emdash-cms/plugin-webhook-notifier": "workspace:*",
|
||||
"astro": "https://pkg.pr.new/astro@94d342d",
|
||||
"emdash": "workspace:*",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "catalog:",
|
||||
"@cloudflare/workers-types": "catalog:",
|
||||
"wrangler": "^4.83.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"workerd"
|
||||
]
|
||||
}
|
||||
}
|
||||
778
infra/cache-demo/seed/seed.json
Normal file
778
infra/cache-demo/seed/seed.json
Normal file
@@ -0,0 +1,778 @@
|
||||
{
|
||||
"$schema": "https://emdashcms.com/seed.schema.json",
|
||||
"version": "1",
|
||||
"meta": {
|
||||
"name": "Blog Starter",
|
||||
"description": "A blog with posts and pages",
|
||||
"author": "EmDash"
|
||||
},
|
||||
|
||||
"settings": {
|
||||
"title": "My Blog",
|
||||
"tagline": "Thoughts on building for the web"
|
||||
},
|
||||
|
||||
"collections": [
|
||||
{
|
||||
"slug": "posts",
|
||||
"label": "Posts",
|
||||
"labelSingular": "Post",
|
||||
"supports": ["drafts", "revisions", "search", "seo"],
|
||||
"commentsEnabled": true,
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "featured_image",
|
||||
"label": "Featured Image",
|
||||
"type": "image"
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText",
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "excerpt",
|
||||
"label": "Excerpt",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "pages",
|
||||
"label": "Pages",
|
||||
"labelSingular": "Page",
|
||||
"supports": ["drafts", "revisions", "search"],
|
||||
"fields": [
|
||||
{
|
||||
"slug": "title",
|
||||
"label": "Title",
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"searchable": true
|
||||
},
|
||||
{
|
||||
"slug": "content",
|
||||
"label": "Content",
|
||||
"type": "portableText",
|
||||
"searchable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"taxonomies": [
|
||||
{
|
||||
"name": "category",
|
||||
"label": "Categories",
|
||||
"labelSingular": "Category",
|
||||
"hierarchical": true,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "development", "label": "Development" },
|
||||
{ "slug": "design", "label": "Design" },
|
||||
{ "slug": "notes", "label": "Notes" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"label": "Tags",
|
||||
"labelSingular": "Tag",
|
||||
"hierarchical": false,
|
||||
"collections": ["posts"],
|
||||
"terms": [
|
||||
{ "slug": "webdev", "label": "Web Development" },
|
||||
{ "slug": "opinion", "label": "Opinion" },
|
||||
{ "slug": "tools", "label": "Tools" },
|
||||
{ "slug": "creativity", "label": "Creativity" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"bylines": [
|
||||
{
|
||||
"id": "byline-editorial",
|
||||
"slug": "emdash-editorial",
|
||||
"displayName": "EmDash Editorial"
|
||||
},
|
||||
{
|
||||
"id": "byline-guest",
|
||||
"slug": "guest-contributor",
|
||||
"displayName": "Guest Contributor",
|
||||
"isGuest": true
|
||||
}
|
||||
],
|
||||
|
||||
"menus": [
|
||||
{
|
||||
"name": "primary",
|
||||
"label": "Primary Navigation",
|
||||
"items": [
|
||||
{ "type": "custom", "label": "Home", "url": "/" },
|
||||
{ "type": "custom", "label": "About", "url": "/pages/about" },
|
||||
{ "type": "custom", "label": "Posts", "url": "/posts" }
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"widgetAreas": [
|
||||
{
|
||||
"name": "sidebar",
|
||||
"label": "Sidebar",
|
||||
"description": "Widget area displayed on single post pages",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:search",
|
||||
"title": "Search"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:categories",
|
||||
"title": "Categories"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:tags",
|
||||
"title": "Tags"
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:recent-posts",
|
||||
"title": "Recent Posts",
|
||||
"settings": {
|
||||
"count": 5,
|
||||
"showDate": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "component",
|
||||
"componentId": "core:archives",
|
||||
"title": "Archives",
|
||||
"settings": {
|
||||
"type": "monthly",
|
||||
"limit": 6
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "footer",
|
||||
"label": "Footer",
|
||||
"description": "Widget area displayed in the site footer",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "content",
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A blog about software, design, and the occasional stray thought."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"sections": [
|
||||
{
|
||||
"slug": "newsletter-signup",
|
||||
"title": "Newsletter Signup",
|
||||
"description": "A call-to-action block for newsletter subscriptions",
|
||||
"keywords": ["newsletter", "subscribe", "email", "cta"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h3",
|
||||
"children": [{ "_type": "span", "text": "Stay in the loop" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Get notified when new posts are published. No spam, unsubscribe anytime."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"slug": "about-author",
|
||||
"title": "About the Author",
|
||||
"description": "Brief author bio for use in posts or pages",
|
||||
"keywords": ["author", "bio", "about"],
|
||||
"source": "theme",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A software developer who writes about building things on the web. Based somewhere with good coffee and reliable internet."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
"content": {
|
||||
"pages": [
|
||||
{
|
||||
"id": "about",
|
||||
"slug": "about",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "About",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A place for writing about software, design, and the occasional stray thought. No posting schedule, no newsletter funnel. Just things I wanted to write down."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Built with Astro and EmDash. The source is open if you want to see how it works."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"id": "post-1",
|
||||
"slug": "building-for-the-long-term",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Building for the Long Term",
|
||||
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a monitor in a dark room",
|
||||
"filename": "building-long-term.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "What survives" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What doesn't survive is code that was written to impress, abstractions built for problems that never materialized, and architectures designed around a framework's opinions rather than the domain's reality."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best code I've written is boring. It reads like prose, does one thing well, and doesn't require a PhD in category theory to understand. The worst code I've written was technically impressive at the time."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [
|
||||
{ "byline": "byline-editorial" },
|
||||
{ "byline": "byline-guest", "roleLabel": "Guest essay" }
|
||||
],
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-2",
|
||||
"slug": "the-case-for-static",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "The Case for Static",
|
||||
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
|
||||
"alt": "Laptop and coffee on a wooden table",
|
||||
"filename": "case-for-static.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The pitch for server-rendered everything was compelling: dynamic content, personalization, real-time data. But most sites don't need most of that most of the time. A blog post doesn't need to be rendered on every request. A product page doesn't change every second."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The performance argument" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "And when you do need dynamic behavior, you can add it surgically. An island of interactivity in a sea of static HTML. The best of both worlds, without paying the cost of either at all times."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bylines": [{ "byline": "byline-editorial" }],
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["webdev", "opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-3",
|
||||
"slug": "learning-in-public",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Learning in Public",
|
||||
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
|
||||
"alt": "Notebook and pen on a desk",
|
||||
"filename": "learning-in-public.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "What I didn't expect was how much the writing itself would accelerate the learning. There's a particular kind of clarity that comes from trying to explain something to someone else. The gaps in your understanding, which you can happily ignore when the knowledge lives only in your head, become painfully obvious when you try to put it into sentences."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The fear of being wrong" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The posts that helped me most weren't written by experts. They were written by people one step ahead of me on the same path, in language that hadn't yet been polished into abstraction. There's a place for that kind of writing, and it's more valuable than most people realize."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-4",
|
||||
"slug": "small-tools-big-impact",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Small Tools, Big Impact",
|
||||
"excerpt": "The best developer tools do one thing well and get out of your way. A love letter to focused software.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1575026615908-666710ae5e47?w=1200&h=800&fit=crop",
|
||||
"alt": "Wrenches and hand tools hanging on a workshop wall",
|
||||
"filename": "small-tools.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "There's a class of software that doesn't get enough appreciation. Not the frameworks or the platforms or the IDEs, but the small, sharp tools that solve one problem so well you stop thinking about them. They become invisible, which is the highest compliment you can pay a tool."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "I'm talking about things like ripgrep, which searches code so fast it changed how I think about searching. Or jq, which makes JSON feel like a first-class data format in the terminal. Or curl, which has been quietly powering the internet's plumbing for decades."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "The Unix philosophy, revisited" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Do one thing well. The advice is old enough to be a cliche, but the best modern tools still follow it. They don't try to be platforms. They don't have plugin ecosystems or configuration languages or startup wizards. They do their job and they compose with other tools that do theirs."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The temptation is always to add more. One more feature, one more option, one more integration. But every addition is a decision someone has to make, a path through the code that has to be maintained, a thing that can break. The best tools resist this. They stay small, and in staying small, they stay reliable."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["tools"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-5",
|
||||
"slug": "designing-with-constraints",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Designing with Constraints",
|
||||
"excerpt": "Limitations aren't obstacles to creativity. They're the structure that makes creativity possible.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1513542789411-b6a5d4f31634?w=1200&h=800&fit=crop",
|
||||
"alt": "Pencils and design tools on a desk",
|
||||
"filename": "designing-with-constraints.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Give a designer a blank canvas and unlimited time, and they'll often produce something mediocre. Give them a tight brief, a small screen, and a deadline, and they'll surprise you. This isn't a paradox - it's how creativity actually works."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Constraints force decisions. When you can't use more than two typefaces, you have to choose carefully. When the page has to load in under a second, every element earns its place. When the interface has to work on a 320px screen, you discover what's truly essential."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Embracing the box" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The web itself is a constraint. HTML flows in one direction. CSS has a box model. Browsers have viewport sizes and font rendering quirks. You can fight these constraints or you can work with them, and the results are dramatically different."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The designs I admire most don't look like they were forced through a framework. They look like they grew naturally from the medium, respecting its grain rather than working against it. That only happens when you treat constraints as creative partners rather than enemies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["design"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-6",
|
||||
"slug": "a-weekend-with-a-side-project",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "A Weekend with a Side Project",
|
||||
"excerpt": "No stakeholders, no deadlines, no Jira tickets. Just you and a dumb idea that might turn into something.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1542831371-29b0f74f9713?w=1200&h=800&fit=crop",
|
||||
"alt": "Code on a screen with a dark theme",
|
||||
"filename": "weekend-side-project.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Saturday morning. Coffee's made, the house is quiet, and I've got an idea that's been nagging at me all week. Not a good idea, necessarily - just a persistent one. A small tool that does a thing I keep doing manually. How hard could it be?"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is the best kind of programming. No requirements document, no sprint planning, no pull request reviews. Just a text editor and a problem. The freedom to make terrible architectural decisions, rewrite everything twice, and follow tangents that turn out to be dead ends."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Why side projects matter" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Side projects are where you learn things your day job would never teach you. Not because the problems are harder, but because you're free to take risks. Try a language you've never used. Build something without a framework. Deploy to a platform you've only read about. The stakes are zero, which makes the learning maximum."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "By Sunday evening, the thing sort of works. It's rough, the error handling is nonexistent, and the README is a single sentence. But it solves the problem I set out to solve, and I learned three things I didn't know on Friday. Not a bad weekend."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["development"],
|
||||
"tag": ["creativity"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-7",
|
||||
"slug": "notes-on-simplicity",
|
||||
"status": "published",
|
||||
"data": {
|
||||
"title": "Notes on Simplicity",
|
||||
"excerpt": "Simplicity isn't the absence of complexity. It's the result of understanding a problem well enough to solve it cleanly.",
|
||||
"featured_image": {
|
||||
"$media": {
|
||||
"url": "https://images.unsplash.com/photo-1559051668-e1fa58f25786?w=1200&h=800&fit=crop",
|
||||
"alt": "Geometric pattern carved into white paper",
|
||||
"filename": "notes-on-simplicity.jpg"
|
||||
}
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Every piece of software starts simple. A few files, a clear purpose, a small surface area. Then features get added, edge cases get handled, and before long you're looking at something that requires a diagram to understand. This isn't inevitable, but it takes discipline to prevent."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The hard part of simplicity isn't the initial design. It's the ongoing resistance to complication. Every feature request, every bug fix, every refactor is an opportunity to add complexity. Saying no is the most important design skill, and the least celebrated."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "h2",
|
||||
"children": [{ "_type": "span", "text": "Removing as a feature" }]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "The best version of a product often has fewer features than the previous one. Not because features were missing, but because someone had the courage to remove things that weren't earning their keep. Every feature has a cost - in maintenance, in cognitive load, in the weight of the interface."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "Simplicity is a practice, not a destination. You never arrive at simple. You just keep asking: is this necessary? Could this be clearer? Is there a way to solve this problem by removing something instead of adding something? The answer is yes more often than you'd expect."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"taxonomies": {
|
||||
"category": ["notes"],
|
||||
"tag": ["opinion"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "post-draft",
|
||||
"slug": "work-in-progress",
|
||||
"status": "draft",
|
||||
"data": {
|
||||
"title": "Work in Progress",
|
||||
"excerpt": "This post is still being written.",
|
||||
"content": [
|
||||
{
|
||||
"_type": "block",
|
||||
"style": "normal",
|
||||
"children": [
|
||||
{
|
||||
"_type": "span",
|
||||
"text": "This is a draft post that won't appear in the public listing."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
279
infra/cache-demo/src/components/PostCard.astro
Normal file
279
infra/cache-demo/src/components/PostCard.astro
Normal file
@@ -0,0 +1,279 @@
|
||||
---
|
||||
import type { MediaValue, ContentBylineCredit } from "emdash";
|
||||
import { Image } from "emdash/ui";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
featuredImage?: MediaValue | string;
|
||||
href: string;
|
||||
date?: Date;
|
||||
readingTime?: number;
|
||||
tags?: Array<{ slug: string; label: string }>;
|
||||
bylines?: ContentBylineCredit[];
|
||||
}
|
||||
|
||||
const {
|
||||
title,
|
||||
excerpt,
|
||||
featuredImage,
|
||||
href,
|
||||
date,
|
||||
readingTime,
|
||||
tags,
|
||||
bylines,
|
||||
} = Astro.props;
|
||||
|
||||
const formattedDate = date
|
||||
? date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: null;
|
||||
---
|
||||
|
||||
<article class="post-card">
|
||||
<a href={href} class="card-link">
|
||||
{
|
||||
featuredImage ? (
|
||||
<div class="card-image">
|
||||
<Image image={featuredImage} />
|
||||
</div>
|
||||
) : (
|
||||
<div class="card-placeholder" />
|
||||
)
|
||||
}
|
||||
<div class="card-body">
|
||||
<div class="card-meta">
|
||||
{
|
||||
bylines && bylines.length > 0 && (
|
||||
<>
|
||||
<div class="card-bylines">
|
||||
{bylines.slice(0, 1).map((credit) => (
|
||||
<span class="card-byline">
|
||||
{credit.byline.avatarMediaId && (
|
||||
<img
|
||||
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
|
||||
alt={credit.byline.displayName}
|
||||
class="card-byline-avatar"
|
||||
/>
|
||||
)}
|
||||
<span class="card-byline-name">
|
||||
{credit.byline.displayName}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
{bylines.length > 1 && (
|
||||
<span
|
||||
class="byline-more"
|
||||
data-tooltip={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
title={bylines
|
||||
.slice(1)
|
||||
.map((c) => c.byline.displayName)
|
||||
.join(", ")}
|
||||
tabindex="0"
|
||||
>
|
||||
+{bylines.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(formattedDate || readingTime) && <span class="meta-dot" />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
{formattedDate && <time>{formattedDate}</time>}
|
||||
{formattedDate && readingTime && <span class="meta-dot" />}
|
||||
{readingTime && <span>{readingTime} min</span>}
|
||||
</div>
|
||||
<h2 class="card-title">{title}</h2>
|
||||
{excerpt && <p class="card-excerpt">{excerpt}</p>}
|
||||
</div>
|
||||
</a>
|
||||
{
|
||||
tags && tags.length > 0 && (
|
||||
<div class="card-tags">
|
||||
{tags.slice(0, 2).map((tag) => (
|
||||
<a href={`/tag/${tag.slug}`} class="card-tag">
|
||||
{tag.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.post-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-link {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .card-image img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.card-placeholder {
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
margin-bottom: var(--spacing-4);
|
||||
}
|
||||
|
||||
.card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
column-gap: var(--spacing-2);
|
||||
row-gap: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
.card-meta time,
|
||||
.card-meta span:not(.meta-dot) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta-dot {
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-muted);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
line-height: var(--leading-snug);
|
||||
letter-spacing: var(--tracking-snug);
|
||||
margin-bottom: var(--spacing-2);
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-link:hover .card-title {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.card-excerpt {
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--leading-relaxed);
|
||||
color: var(--color-text-secondary);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
margin-top: var(--spacing-3);
|
||||
}
|
||||
|
||||
.card-tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-2);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
color var(--transition-fast),
|
||||
background var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* Byline styles */
|
||||
.card-bylines {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-byline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.card-byline-avatar {
|
||||
width: var(--avatar-size-xs);
|
||||
height: var(--avatar-size-xs);
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.card-byline-name {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.byline-more {
|
||||
position: relative;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-muted);
|
||||
margin-left: 2px;
|
||||
cursor: default;
|
||||
border-radius: var(--radius);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.byline-more:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.byline-more[data-tooltip]:hover::after,
|
||||
.byline-more[data-tooltip]:focus-visible::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
white-space: nowrap;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 400;
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border-radius: var(--radius);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
</style>
|
||||
45
infra/cache-demo/src/components/TagList.astro
Normal file
45
infra/cache-demo/src/components/TagList.astro
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
interface Props {
|
||||
tags: Array<{ slug: string; label: string }>;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const { tags, class: className } = Astro.props;
|
||||
---
|
||||
|
||||
{tags.length > 0 && (
|
||||
<ul class:list={["tag-list", className]}>
|
||||
{tags.map((tag) => (
|
||||
<li>
|
||||
<a href={`/tag/${tag.slug}`} class="tag">{tag.label}</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<style>
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-2);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
padding: var(--tag-padding-y) var(--spacing-3);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.tag:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-border);
|
||||
}
|
||||
</style>
|
||||
1010
infra/cache-demo/src/layouts/Base.astro
Normal file
1010
infra/cache-demo/src/layouts/Base.astro
Normal file
File diff suppressed because it is too large
Load Diff
13
infra/cache-demo/src/live.config.ts
Normal file
13
infra/cache-demo/src/live.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* EmDash Live Content Collections
|
||||
*
|
||||
* Defines the _emdash collection that handles all content types from the database.
|
||||
* Query specific types using getEmDashCollection() and getEmDashEntry().
|
||||
*/
|
||||
|
||||
import { defineLiveCollection } from "astro:content";
|
||||
import { emdashLoader } from "emdash/runtime";
|
||||
|
||||
export const collections = {
|
||||
_emdash: defineLiveCollection({ loader: emdashLoader() }),
|
||||
};
|
||||
33
infra/cache-demo/src/pages/404.astro
Normal file
33
infra/cache-demo/src/pages/404.astro
Normal file
@@ -0,0 +1,33 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
---
|
||||
|
||||
<Base title="Page not found">
|
||||
<div class="not-found">
|
||||
<h1>404</h1>
|
||||
<p>The page you're looking for doesn't exist.</p>
|
||||
<a href="/">Go back home</a>
|
||||
</div>
|
||||
</Base>
|
||||
|
||||
<style>
|
||||
.not-found {
|
||||
text-align: center;
|
||||
padding: var(--spacing-24) var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found h1 {
|
||||
font-size: var(--font-size-5xl);
|
||||
margin-bottom: var(--spacing-2);
|
||||
color: var(--color-border);
|
||||
}
|
||||
|
||||
.not-found p {
|
||||
color: var(--color-muted);
|
||||
margin-bottom: var(--spacing-6);
|
||||
}
|
||||
|
||||
.not-found a {
|
||||
color: var(--color-text);
|
||||
}
|
||||
</style>
|
||||
129
infra/cache-demo/src/pages/category/[slug].astro
Normal file
129
infra/cache-demo/src/pages/category/[slug].astro
Normal 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>
|
||||
463
infra/cache-demo/src/pages/index.astro
Normal file
463
infra/cache-demo/src/pages/index.astro
Normal 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(--spacing-1) 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);
|
||||
}
|
||||
|
||||
.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>
|
||||
108
infra/cache-demo/src/pages/pages/[slug].astro
Normal file
108
infra/cache-demo/src/pages/pages/[slug].astro
Normal 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>
|
||||
970
infra/cache-demo/src/pages/posts/[slug].astro
Normal file
970
infra/cache-demo/src/pages/posts/[slug].astro
Normal 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;
|
||||
}
|
||||
|
||||
.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(--spacing-1) 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);
|
||||
}
|
||||
|
||||
.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);
|
||||
grid-template-rows: repeat(5, auto);
|
||||
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>
|
||||
272
infra/cache-demo/src/pages/posts/index.astro
Normal file
272
infra/cache-demo/src/pages/posts/index.astro
Normal 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-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);
|
||||
}
|
||||
|
||||
.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>
|
||||
70
infra/cache-demo/src/pages/rss.xml.ts
Normal file
70
infra/cache-demo/src/pages/rss.xml.ts
Normal 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, "&"],
|
||||
[/</g, "<"],
|
||||
[/>/g, ">"],
|
||||
[/"/g, """],
|
||||
[/'/g, "'"],
|
||||
] as const;
|
||||
|
||||
function escapeXml(str: string): string {
|
||||
let result = str;
|
||||
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
182
infra/cache-demo/src/pages/search.astro
Normal file
182
infra/cache-demo/src/pages/search.astro
Normal 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>
|
||||
131
infra/cache-demo/src/pages/tag/[slug].astro
Normal file
131
infra/cache-demo/src/pages/tag/[slug].astro
Normal 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>
|
||||
108
infra/cache-demo/src/styles/theme.css
Normal file
108
infra/cache-demo/src/styles/theme.css
Normal 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 h2–h6, 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
|
||||
*/
|
||||
}
|
||||
66
infra/cache-demo/src/utils/reading-time.ts
Normal file
66
infra/cache-demo/src/utils/reading-time.ts
Normal 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`;
|
||||
}
|
||||
24
infra/cache-demo/src/utils/site-identity.ts
Normal file
24
infra/cache-demo/src/utils/site-identity.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
5
infra/cache-demo/src/worker.ts
Normal file
5
infra/cache-demo/src/worker.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import handler from "@astrojs/cloudflare/entrypoints/server";
|
||||
|
||||
export { PluginBridge } from "@emdash-cms/cloudflare/sandbox";
|
||||
|
||||
export default handler;
|
||||
7
infra/cache-demo/tsconfig.json
Normal file
7
infra/cache-demo/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src", ".astro/types.d.ts", "emdash-env.d.ts"]
|
||||
}
|
||||
40
infra/cache-demo/wrangler.jsonc
Normal file
40
infra/cache-demo/wrangler.jsonc
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "emdash-demo-cache",
|
||||
"main": "./src/worker.ts",
|
||||
"account_id": "1f74638c495bc9f0330ce5c8e64c1b6b",
|
||||
"compatibility_date": "2026-02-24",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"placement": { "mode": "targeted", "region": "aws:eu-west-2" },
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "cache-demo.emdashcms.com",
|
||||
"zone_name": "emdashcms.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash-demo-blog",
|
||||
"database_id": "9361329b-a0f6-4c59-a184-74747a705e54",
|
||||
},
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "MEDIA",
|
||||
"bucket_name": "emdash-demo-media",
|
||||
},
|
||||
],
|
||||
"worker_loaders": [
|
||||
{
|
||||
"binding": "LOADER",
|
||||
},
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "SESSION",
|
||||
"id": "045dd0600977420992277da020ae2df3",
|
||||
},
|
||||
],
|
||||
}
|
||||
142
infra/perf-monitor/README.md
Normal file
142
infra/perf-monitor/README.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Emdash Perf Monitor
|
||||
|
||||
Tracks cold start / TTFB of the emdash demo sites over time from multiple regions. Two sites are measured in parallel so the effect of Astro's experimental cache provider can be compared head-to-head:
|
||||
|
||||
- `blog` -- `blog-demo.emdashcms.com` (baseline, catalog Astro)
|
||||
- `cache` -- `cache-demo.emdashcms.com` (prerelease Astro with `cacheCloudflare()` enabled)
|
||||
|
||||
Each measurement row is tagged with a `site` column matching one of those ids.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Coordinator Worker** (`emdash-perf-coordinator`) owns the D1 database, cron trigger, queue consumer, HTTP API, and frontend dashboard. Served at `https://perf.emdashcms.com`.
|
||||
- **4 Probe Workers** (`emdash-perf-probe-{use,euw,ape,aps}`) are placed near AWS regions via `placement.region`. They receive measurement requests from the coordinator via service bindings and run `fetch()` timing from their placed location.
|
||||
- **D1 database** (`emdash_perf`) stores all measurements, tagged by `source`: `deploy` (queue-triggered, has SHA + PR) or `cron` (ambient baseline, untagged).
|
||||
- **Cloudflare Queue** (`emdash-perf-deploy-events`) subscribes to `cf.workersBuilds.worker.build.succeeded` events. The coordinator consumes these, filters for the baseline demo Worker, resolves the PR via the GitHub API, and runs a measurement against every registered site. This is the primary attribution path; see `src/routes.ts` for the site registry.
|
||||
|
||||
All five Workers are built from this directory by the Cloudflare Vite plugin -- the coordinator entry is `src/index.ts` and the four probes are defined as `auxiliaryWorkers` in `vite.config.ts`.
|
||||
|
||||
## Measurement triggers
|
||||
|
||||
| Trigger | When | `source` | Sites | SHA | PR | On graph? | Persisted? |
|
||||
| ---------------------- | ---------------------------------- | -------- | ------------ | ---------- | -------- | --------- | ---------- |
|
||||
| Queue event | Every successful `blog-demo` build | `deploy` | all | from event | resolved | yes | yes |
|
||||
| Cron (`*/30 * * * *`) | Every 30 min | `cron` | all | null | null | yes | yes |
|
||||
| `pnpm trigger` | Private/quiet check (default) | n/a | all (or one) | n/a | n/a | no | **no** |
|
||||
| `pnpm trigger --store` | Manual, persisted | `manual` | all (or one) | optional | optional | **no** | yes |
|
||||
|
||||
The queue is the deploy-attribution path. The cron is a safety net that fills gaps between deploys and catches regressions the queue might miss.
|
||||
|
||||
`pnpm trigger` defaults to ephemeral: the probes run for real, but the coordinator skips the database insert and just returns the results to stdout. Use this for private/local checks you don't want on the dashboard.
|
||||
|
||||
Passing `--store` persists the run as `source=manual`. Stored manual runs land in the results table with a yellow `manual` badge but are excluded from the line chart, the summary cards, and the 7-day rolling medians so they don't skew the baseline.
|
||||
|
||||
## Manual triggers
|
||||
|
||||
```bash
|
||||
# Default: run the probes, print results, record nothing.
|
||||
# First invocation opens a browser for Cloudflare Access login; subsequent
|
||||
# invocations reuse the token until the Access session expires.
|
||||
pnpm trigger
|
||||
|
||||
# Persist the run as source=manual (appears in the results table)
|
||||
pnpm trigger -- --store --note "pre-cold-start-fix baseline"
|
||||
|
||||
# Attach a SHA and/or PR number to a persisted run
|
||||
pnpm trigger -- --store --sha 1a2b3c4 --pr 532 --note "PR #532 preview"
|
||||
```
|
||||
|
||||
Auth is handled by a Cloudflare Access policy on `POST /api/trigger`
|
||||
|
||||
## First-time setup
|
||||
|
||||
```bash
|
||||
# 1. Create the D1 database and apply the initial schema
|
||||
wrangler d1 create emdash_perf
|
||||
# copy the database_id into wrangler.jsonc
|
||||
|
||||
wrangler d1 execute emdash_perf --remote --file=schema.sql
|
||||
pnpm db:migrations:apply # any incremental migrations on top
|
||||
|
||||
# 2. Create the deploy events queue and DLQ
|
||||
wrangler queues create emdash-perf-deploy-events
|
||||
wrangler queues create emdash-perf-deploy-events-dlq
|
||||
|
||||
# 3. Build and deploy all 5 Workers
|
||||
pnpm deploy
|
||||
|
||||
# 4. Subscribe the queue to Workers Builds events.
|
||||
# (No wrangler command for this yet -- use the CF dashboard or API:
|
||||
# https://developers.cloudflare.com/queues/event-subscriptions/manage-event-subscriptions/)
|
||||
# Source: Workers Builds
|
||||
# Events: build.succeeded (at minimum)
|
||||
# Queue: emdash-perf-deploy-events
|
||||
|
||||
# 5. (Optional, to enable manual triggers) Add a Cloudflare Access policy
|
||||
# on POST /api/trigger. See "Access setup" above.
|
||||
```
|
||||
|
||||
No secrets required. PR lookup hits the public GitHub API unauthenticated
|
||||
(60 req/hr limit, plenty for one lookup per deploy).
|
||||
|
||||
## Deploy order
|
||||
|
||||
The coordinator's service bindings require the probes to exist first. `pnpm deploy` handles this: it builds, deploys all 4 probes, then deploys the coordinator.
|
||||
|
||||
## Dev
|
||||
|
||||
```bash
|
||||
pnpm dev # Vite dev server, all 5 Workers via Miniflare
|
||||
```
|
||||
|
||||
Open `http://localhost:5173` for the dashboard. API is at `/api/*`. Queue events can't be exercised locally without manual message publishing -- rely on the live environment or the next cron tick to verify the measurement path.
|
||||
|
||||
Local manual trigger (no Access locally):
|
||||
|
||||
```bash
|
||||
curl -sS -X POST http://localhost:5173/api/trigger \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"note":"local test"}'
|
||||
```
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Method | Auth | Purpose |
|
||||
| -------------- | ------ | --------- | ------------------------------------------------- |
|
||||
| `/` | GET | none | Dashboard |
|
||||
| `/api/config` | GET | none | Target URL, available routes and regions |
|
||||
| `/api/summary` | GET | none | Latest result per route/region + rolling medians |
|
||||
| `/api/results` | GET | none | Filtered historical results |
|
||||
| `/api/chart` | GET | none | Time series for charting (with PR markers) |
|
||||
| `/api/trigger` | POST | CF Access | Run an ad-hoc measurement, tagged `source=manual` |
|
||||
|
||||
All GET endpoints are read-only. `POST /api/trigger` is the only state-changing endpoint and is expected to be protected by a Cloudflare Access policy at the edge.
|
||||
|
||||
## Schema changes
|
||||
|
||||
D1's native migrations are wired up (`migrations_dir` in `wrangler.jsonc`).
|
||||
|
||||
```bash
|
||||
pnpm db:migrations:list # show pending migrations
|
||||
pnpm db:migrations:apply # apply pending migrations
|
||||
pnpm db:migrations:create # scaffold a new migration file
|
||||
```
|
||||
|
||||
`schema.sql` is the desired end state for fresh installs only. For incremental changes on an existing database, add a file under `migrations/` and apply it -- don't rely on editing `schema.sql` to take effect.
|
||||
|
||||
## Types
|
||||
|
||||
Binding types come from `wrangler types`, which reads `wrangler.jsonc` and writes `worker-configuration.d.ts`. The generated file is committed so `tsc` doesn't need wrangler to run first.
|
||||
|
||||
Re-run after any binding change:
|
||||
|
||||
```bash
|
||||
pnpm cf-typegen
|
||||
```
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Trigger worker name**: `TRIGGER_WORKER_NAME` in `src/routes.ts` is the Worker whose `build.succeeded` event drives deploy-attributed runs. Events for any other Worker are discarded (the cron job still measures every site on its own schedule). Since every registered site rebuilds from the same main-branch commit, one event triggers a measurement for all of them. If the baseline demo is ever renamed, update this constant.
|
||||
- **Adding a site**: add an entry to `SITES` in `src/routes.ts` with a stable `id` (stored in `perf_results.site`), `targetUrl`, and Worker name. Existing rows continue to use their recorded site id.
|
||||
- **PR lookup**: hits the public GitHub API unauthenticated (60 req/hr per IP). One call per deploy, so rate limits are a non-issue. If deploy rate ever gets anywhere near that, add a fine-grained PAT via `wrangler secret put GITHUB_TOKEN` and pass it in `src/github.ts`.
|
||||
- **DLQ**: failed messages retry 3x, then go to `emdash-perf-deploy-events-dlq`. Check this periodically if deploy-attributed results stop appearing.
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add columns for Server-Timing capture and free-form run notes.
|
||||
--
|
||||
-- cold_server_timings stores the parsed Server-Timing header from the cold
|
||||
-- request as a JSON object keyed by timing name:
|
||||
-- { "<name>": { "dur": <number>, "desc"?: <string> } }
|
||||
-- Only the cold response is stored -- warm requests are aggregated into
|
||||
-- medians, so keeping N server-timing blobs per route makes no sense.
|
||||
--
|
||||
-- note is a free-form label, primarily for manual triggers
|
||||
-- (e.g. "pre-cold-start-fix baseline") but available for any source.
|
||||
ALTER TABLE perf_results ADD COLUMN cold_server_timings TEXT;
|
||||
ALTER TABLE perf_results ADD COLUMN note TEXT;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Add column for the median-per-metric warm Server-Timing snapshot.
|
||||
--
|
||||
-- The original capture only stored cold Server-Timing (`cold_server_timings`).
|
||||
-- That's useful for cold-start investigation but useless for steady-state
|
||||
-- measurements -- which is what most performance work actually moves.
|
||||
--
|
||||
-- `warm_server_timings` stores the median duration per metric across all
|
||||
-- warm requests in a single probe, in the same JSON shape as
|
||||
-- `cold_server_timings`:
|
||||
-- { "<name>": { "dur": <number>, "desc"?: <string> } }
|
||||
--
|
||||
-- Null when the target site didn't emit Server-Timing on warm responses, or
|
||||
-- when no warm requests were issued.
|
||||
ALTER TABLE perf_results ADD COLUMN warm_server_timings TEXT;
|
||||
7
infra/perf-monitor/migrations/0003_add_site.sql
Normal file
7
infra/perf-monitor/migrations/0003_add_site.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- Tag each measurement with the demo site it came from. Existing rows all
|
||||
-- belong to the baseline blog-demo; the cache-demo site was added later.
|
||||
|
||||
ALTER TABLE perf_results ADD COLUMN site TEXT NOT NULL DEFAULT 'blog';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_site_ts ON perf_results(site, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_site_route_region_ts ON perf_results(site, route, region, timestamp);
|
||||
24
infra/perf-monitor/package.json
Normal file
24
infra/perf-monitor/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@emdash-cms/perf-monitor",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"deploy": "pnpm build && pnpm deploy:probes && wrangler deploy",
|
||||
"deploy:probes": "for dir in dist/emdash_perf_probe_*; do wrangler deploy -c $dir/wrangler.json; done",
|
||||
"cf-typegen": "wrangler types",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:migrations:list": "wrangler d1 migrations list emdash_perf --remote",
|
||||
"db:migrations:apply": "wrangler d1 migrations apply emdash_perf --remote",
|
||||
"db:migrations:create": "wrangler d1 migrations create emdash_perf",
|
||||
"trigger": "node scripts/trigger.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.0.0",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^6.0.0",
|
||||
"wrangler": "catalog:"
|
||||
}
|
||||
}
|
||||
31
infra/perf-monitor/probe/src/index.ts
Normal file
31
infra/perf-monitor/probe/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Perf probe Worker -- deployed per-region with placement hints.
|
||||
* Receives measurement requests via service binding fetch(),
|
||||
* runs the measurements from its placed location, returns results.
|
||||
*/
|
||||
|
||||
import { measureRoutes } from "./measure.js";
|
||||
import type { MeasureRequest, MeasureResponse } from "./measure.js";
|
||||
|
||||
export default {
|
||||
async fetch(request: Request): Promise<Response> {
|
||||
if (request.method !== "POST") {
|
||||
return new Response("Method not allowed", { status: 405 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json<MeasureRequest & { region?: string }>();
|
||||
const results = await measureRoutes(body);
|
||||
|
||||
const response: MeasureResponse = {
|
||||
results,
|
||||
probeRegion: body.region ?? "unknown",
|
||||
};
|
||||
|
||||
return Response.json(response);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
},
|
||||
} satisfies ExportedHandler;
|
||||
212
infra/perf-monitor/probe/src/measure.ts
Normal file
212
infra/perf-monitor/probe/src/measure.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
/** Measurement logic -- runs inside the placed probe Worker. */
|
||||
|
||||
export interface MeasureRequest {
|
||||
targetUrl: string;
|
||||
routes: Array<{ path: string; label: string }>;
|
||||
warmRequests: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed Server-Timing header. Keyed by timing name. `desc` is optional.
|
||||
* Example: { render: { dur: 42, desc: "Page render" }, mw: { dur: 58 } }
|
||||
*/
|
||||
export type ServerTimings = Record<string, { dur: number; desc?: string }>;
|
||||
|
||||
export interface RouteResult {
|
||||
path: string;
|
||||
label: string;
|
||||
coldTtfbMs: number;
|
||||
/**
|
||||
* Median warm-request TTFB. Null if warmRequests was 0 and no warm
|
||||
* samples were taken — caller should fall back to coldTtfbMs in that case.
|
||||
*/
|
||||
warmTtfbMs: number | null;
|
||||
/** p95 warm-request TTFB. Null when no warm samples were taken. */
|
||||
p95TtfbMs: number | null;
|
||||
statusCode: number;
|
||||
cfColo: string | null;
|
||||
cfPlacement: string | null;
|
||||
/** Parsed from the cold response. Null if header absent or unparseable. */
|
||||
coldServerTimings: ServerTimings | null;
|
||||
/**
|
||||
* Median of each Server-Timing metric across all warm requests.
|
||||
* Null if no warm responses carried the header or no warm requests
|
||||
* were issued. Use this to isolate steady-state render/middleware/
|
||||
* runtime cost, independent of cold-start.
|
||||
*/
|
||||
warmServerTimings: ServerTimings | null;
|
||||
}
|
||||
|
||||
export interface MeasureResponse {
|
||||
results: RouteResult[];
|
||||
probeRegion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the Server-Timing response header.
|
||||
*
|
||||
* Grammar (RFC 8673 §2):
|
||||
* Server-Timing: metric[;param]*[, metric[;param]*]*
|
||||
* param = dur=<number> | desc="<string>" | desc=<token>
|
||||
*
|
||||
* We only extract `dur` and `desc` and silently skip malformed entries.
|
||||
* Unknown params are ignored rather than rejected so future additions
|
||||
* upstream don't cause us to drop data.
|
||||
*/
|
||||
export function parseServerTiming(header: string | null): ServerTimings | null {
|
||||
if (!header) return null;
|
||||
const out: ServerTimings = {};
|
||||
for (const rawEntry of header.split(",")) {
|
||||
const parts = rawEntry.split(";").map((p) => p.trim());
|
||||
const name = parts[0];
|
||||
if (!name) continue;
|
||||
const entry: { dur: number; desc?: string } = { dur: 0 };
|
||||
let sawDur = false;
|
||||
for (const param of parts.slice(1)) {
|
||||
const eq = param.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
const key = param.slice(0, eq).trim();
|
||||
let value = param.slice(eq + 1).trim();
|
||||
// desc may be quoted
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (key === "dur") {
|
||||
const n = Number(value);
|
||||
if (Number.isFinite(n)) {
|
||||
entry.dur = n;
|
||||
sawDur = true;
|
||||
}
|
||||
} else if (key === "desc") {
|
||||
entry.desc = value;
|
||||
}
|
||||
}
|
||||
if (sawDur) out[name] = entry;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure TTFB for a single URL.
|
||||
* Returns wall-clock time from fetch start to first byte (headers received).
|
||||
*/
|
||||
async function measureTtfb(url: string): Promise<{
|
||||
ttfbMs: number;
|
||||
statusCode: number;
|
||||
cfColo: string | null;
|
||||
cfPlacement: string | null;
|
||||
serverTimings: ServerTimings | null;
|
||||
}> {
|
||||
const start = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"User-Agent": "emdash-perf-probe/1.0",
|
||||
// Bust any edge cache
|
||||
"Cache-Control": "no-cache",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
const ttfbMs = performance.now() - start;
|
||||
|
||||
// Consume the body so the connection is properly released
|
||||
await response.arrayBuffer();
|
||||
|
||||
// Extract cf-ray colo: format is "<ray-id>-<COLO>"
|
||||
const cfRay = response.headers.get("cf-ray");
|
||||
const cfColo = cfRay?.split("-").pop() ?? null;
|
||||
const cfPlacement = response.headers.get("cf-placement");
|
||||
const serverTimings = parseServerTiming(response.headers.get("server-timing"));
|
||||
|
||||
return { ttfbMs, statusCode: response.status, cfColo, cfPlacement, serverTimings };
|
||||
}
|
||||
|
||||
/** Compute the median of an array. */
|
||||
function median(values: number[]): number {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
if (sorted.length % 2 === 0) {
|
||||
return (sorted[mid - 1]! + sorted[mid]!) / 2;
|
||||
}
|
||||
return sorted[mid]!;
|
||||
}
|
||||
|
||||
/** Compute p95 of an array. */
|
||||
function p95(values: number[]): number {
|
||||
const sorted = values.toSorted((a, b) => a - b);
|
||||
const idx = Math.ceil(sorted.length * 0.95) - 1;
|
||||
return sorted[Math.max(0, idx)]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run measurements for all routes.
|
||||
* For each route: 1 cold request (cache-busted with unique query param),
|
||||
* then N warm requests. Returns structured results.
|
||||
*/
|
||||
export async function measureRoutes(req: MeasureRequest): Promise<RouteResult[]> {
|
||||
const results: RouteResult[] = [];
|
||||
|
||||
for (const route of req.routes) {
|
||||
const url = `${req.targetUrl}${route.path}`;
|
||||
|
||||
// Cold request -- add a unique query param to avoid any isolate reuse
|
||||
const coldUrl = url + (url.includes("?") ? "&" : "?") + `_perf_cold=${Date.now()}`;
|
||||
const cold = await measureTtfb(coldUrl);
|
||||
|
||||
// Warm requests — keep per-metric samples so we can median each one.
|
||||
const warmTimings: number[] = [];
|
||||
const warmMetricSamples: Record<string, { durs: number[]; desc?: string }> = {};
|
||||
let lastStatusCode = cold.statusCode;
|
||||
for (let i = 0; i < req.warmRequests; i++) {
|
||||
const warm = await measureTtfb(url);
|
||||
warmTimings.push(warm.ttfbMs);
|
||||
lastStatusCode = warm.statusCode;
|
||||
if (warm.serverTimings) {
|
||||
for (const [name, entry] of Object.entries(warm.serverTimings)) {
|
||||
const acc = warmMetricSamples[name] ?? { durs: [], desc: entry.desc };
|
||||
acc.durs.push(entry.dur);
|
||||
if (!acc.desc && entry.desc) acc.desc = entry.desc;
|
||||
warmMetricSamples[name] = acc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse per-metric samples into medians so the stored shape
|
||||
// mirrors coldServerTimings.
|
||||
const warmServerTimings: ServerTimings | null = Object.keys(warmMetricSamples).length
|
||||
? Object.fromEntries(
|
||||
Object.entries(warmMetricSamples).map(([name, { durs, desc }]) => {
|
||||
const entry: { dur: number; desc?: string } = {
|
||||
dur: Math.round(median(durs) * 100) / 100,
|
||||
};
|
||||
if (desc) entry.desc = desc;
|
||||
return [name, entry];
|
||||
}),
|
||||
)
|
||||
: null;
|
||||
|
||||
// Handle the (uncommon) warmRequests=0 case: without warm samples,
|
||||
// median/p95 would compute against an empty array and produce NaN.
|
||||
// Report the cold TTFB in both slots so the row remains valid;
|
||||
// warm timings are reported as null so downstream code knows there's
|
||||
// no warm breakdown to render.
|
||||
const hasWarm = warmTimings.length > 0;
|
||||
const warmTtfbMs = hasWarm ? Math.round(median(warmTimings) * 100) / 100 : null;
|
||||
const p95TtfbMs = hasWarm ? Math.round(p95(warmTimings) * 100) / 100 : null;
|
||||
|
||||
results.push({
|
||||
path: route.path,
|
||||
label: route.label,
|
||||
coldTtfbMs: Math.round(cold.ttfbMs * 100) / 100,
|
||||
warmTtfbMs,
|
||||
p95TtfbMs,
|
||||
statusCode: lastStatusCode,
|
||||
cfColo: cold.cfColo,
|
||||
cfPlacement: cold.cfPlacement,
|
||||
coldServerTimings: cold.serverTimings,
|
||||
warmServerTimings,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
991
infra/perf-monitor/public/index.html
Normal file
991
infra/perf-monitor/public/index.html
Normal file
@@ -0,0 +1,991 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Emdash Perf Monitor</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3/dist/chartjs-plugin-annotation.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0c0c0e;
|
||||
--bg-card: #16161a;
|
||||
--bg-hover: #1e1e24;
|
||||
--border: #2a2a32;
|
||||
--text: #e0e0e6;
|
||||
--text-muted: #888894;
|
||||
--text-dim: #5c5c66;
|
||||
--accent: #4dabf7;
|
||||
--accent-dim: #2a6cb5;
|
||||
--green: #51cf66;
|
||||
--yellow: #fcc419;
|
||||
--red: #ff6b6b;
|
||||
--orange: #ff922b;
|
||||
--purple: #b197fc;
|
||||
--radius: 6px;
|
||||
--font: "SF Mono", "Cascadia Code", "Fira Code", Menlo, monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16px;
|
||||
margin-bottom: 32px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
header .target {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
header .last-updated {
|
||||
margin-left: auto;
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.controls label {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.controls select {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls select:hover {
|
||||
border-color: var(--accent-dim);
|
||||
}
|
||||
|
||||
/* Summary cards */
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.summary-card .label {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.summary-card .value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.summary-card .meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.good {
|
||||
color: var(--green);
|
||||
}
|
||||
.warn {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.bad {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
/* Chart area */
|
||||
.chart-section {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.chart-section h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chart-wrapper canvas {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Table */
|
||||
.results-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.results-table th {
|
||||
text-align: left;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.results-table td {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.results-table tr:hover td {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.results-table .mono {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pr-badge {
|
||||
display: inline-block;
|
||||
background: var(--accent-dim);
|
||||
color: var(--accent);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.pr-badge:hover {
|
||||
background: #3577c5;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.source-cron {
|
||||
background: #1a1f2a;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.source-deploy {
|
||||
background: #1a3a2a;
|
||||
color: var(--green);
|
||||
}
|
||||
.source-manual {
|
||||
background: #3a2a1a;
|
||||
color: var(--yellow);
|
||||
}
|
||||
|
||||
.timing-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
margin-right: 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
background: #1e1e24;
|
||||
color: var(--text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.timing-tag strong {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.note-text {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a.sha-link {
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.sha-link:hover {
|
||||
color: var(--text-muted);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.region-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.region-use {
|
||||
background: #1a3a2a;
|
||||
color: var(--green);
|
||||
}
|
||||
.region-euw {
|
||||
background: #1a2a3a;
|
||||
color: var(--accent);
|
||||
}
|
||||
.region-ape {
|
||||
background: #2a1a3a;
|
||||
color: var(--purple);
|
||||
}
|
||||
.region-aps {
|
||||
background: #3a2a1a;
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.error-msg {
|
||||
background: #2a1a1a;
|
||||
border: 1px solid #3a2020;
|
||||
color: var(--red);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Legend for chart */
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-legend .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chart-legend .swatch {
|
||||
width: 12px;
|
||||
height: 3px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.chart-legend .swatch-marker {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--red);
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>emdash perf</h1>
|
||||
<span class="target" id="target-label"></span>
|
||||
<span class="last-updated" id="last-updated"></span>
|
||||
</header>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label>Site</label>
|
||||
<select id="site-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Route</label>
|
||||
<select id="route-select"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Region</label>
|
||||
<select id="region-select">
|
||||
<option value="all">All Regions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Period</label>
|
||||
<select id="period-select">
|
||||
<option value="1h">1 hour</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="7d" selected>7 days</option>
|
||||
<option value="30d">30 days</option>
|
||||
<option value="90d">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary-grid" id="summary-cards">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h2>Cold Start TTFB</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="cold-chart" height="300"></canvas>
|
||||
</div>
|
||||
<div class="chart-legend">
|
||||
<div class="item">
|
||||
<span class="swatch" style="background: var(--green)"></span>
|
||||
US East
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="swatch" style="background: var(--accent)"></span>
|
||||
Europe West
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="swatch" style="background: var(--purple)"></span>
|
||||
Asia Pacific East
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="swatch" style="background: var(--orange)"></span>
|
||||
Asia Pacific South
|
||||
</div>
|
||||
<div class="item">
|
||||
<span class="swatch-marker"></span>
|
||||
Deploy
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h2>Warm TTFB (median)</h2>
|
||||
<div class="chart-wrapper">
|
||||
<canvas id="warm-chart" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chart-section">
|
||||
<h2>Recent Results</h2>
|
||||
<div class="chart-wrapper" style="overflow-x: auto">
|
||||
<table class="results-table" id="results-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Route</th>
|
||||
<th>Region</th>
|
||||
<th>Cold TTFB</th>
|
||||
<th>Warm TTFB</th>
|
||||
<th>P95</th>
|
||||
<th>Status</th>
|
||||
<th>Colo</th>
|
||||
<th>Cold Timings</th>
|
||||
<th>Warm Timings</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="results-body">
|
||||
<tr>
|
||||
<td colspan="11" class="loading">Loading...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const GITHUB_REPO = "emdash-cms/emdash";
|
||||
const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
||||
|
||||
const REGION_COLORS = {
|
||||
use: "#51cf66",
|
||||
euw: "#4dabf7",
|
||||
ape: "#b197fc",
|
||||
aps: "#ff922b",
|
||||
};
|
||||
|
||||
const REGION_LABELS = {
|
||||
use: "US East",
|
||||
euw: "Europe West",
|
||||
ape: "Asia Pacific East",
|
||||
aps: "Asia Pacific South",
|
||||
};
|
||||
|
||||
let coldChart = null;
|
||||
let warmChart = null;
|
||||
let configData = null;
|
||||
|
||||
async function fetchJson(url) {
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`${resp.status} ${resp.statusText}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function periodToSince(period) {
|
||||
const now = new Date();
|
||||
switch (period) {
|
||||
case "1h":
|
||||
return new Date(now - 60 * 60 * 1000).toISOString();
|
||||
case "24h":
|
||||
return new Date(now - 24 * 60 * 60 * 1000).toISOString();
|
||||
case "7d":
|
||||
return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case "30d":
|
||||
return new Date(now - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
case "90d":
|
||||
return new Date(now - 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
default:
|
||||
return new Date(now - 7 * 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// 7d/30d/90d views show per-point samples that are too spiky to read.
|
||||
// Bucket by UTC day so the trend is visible. Median, not mean, so a
|
||||
// single cron spike doesn't pull the bucket.
|
||||
const DAILY_BUCKET_PERIODS = new Set(["7d", "30d", "90d"]);
|
||||
|
||||
/**
|
||||
* Parse a D1-stored timestamp ("YYYY-MM-DD HH:MM:SS", no TZ) as UTC.
|
||||
* `new Date("YYYY-MM-DD HH:MM:SS")` is implementation-defined and most
|
||||
* browsers treat it as *local* time, which shifts samples across UTC
|
||||
* day boundaries when we bucket with `getUTC*`. Normalize first.
|
||||
*/
|
||||
function parseStoredTimestamp(ts) {
|
||||
if (!ts) return null;
|
||||
if (ts.includes("T") || ts.endsWith("Z")) return new Date(ts);
|
||||
return new Date(ts.replace(" ", "T") + "Z");
|
||||
}
|
||||
|
||||
function median(nums) {
|
||||
const sorted = nums.filter((n) => n != null).sort((a, b) => a - b);
|
||||
if (sorted.length === 0) return null;
|
||||
const mid = sorted.length >> 1;
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
function bucketByUtcDay(points) {
|
||||
const byDay = new Map();
|
||||
for (const p of points) {
|
||||
const d = p.x instanceof Date ? p.x : new Date(p.x);
|
||||
const day = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
if (!byDay.has(day)) byDay.set(day, []);
|
||||
byDay.get(day).push(p.y);
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([day, ys]) => ({
|
||||
x: new Date(`${day}T12:00:00Z`),
|
||||
y: median(ys),
|
||||
}))
|
||||
.filter((p) => p.y != null)
|
||||
.sort((a, b) => a.x - b.x);
|
||||
}
|
||||
|
||||
function formatMs(ms) {
|
||||
if (ms == null) return "-";
|
||||
if (ms < 1000) return Math.round(ms) + "ms";
|
||||
return (ms / 1000).toFixed(2) + "s";
|
||||
}
|
||||
|
||||
function ttfbClass(ms, threshold) {
|
||||
if (ms == null) return "";
|
||||
if (ms <= threshold * 0.5) return "good";
|
||||
if (ms <= threshold) return "warn";
|
||||
return "bad";
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = parseStoredTimestamp(ts);
|
||||
if (!d) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getMonth() + 1)}/${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
const HTML_ESCAPES = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
function escapeHtml(s) {
|
||||
if (s == null) return "";
|
||||
return String(s).replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]);
|
||||
}
|
||||
function escapeAttr(s) {
|
||||
// attribute-safe subset of characters
|
||||
return escapeHtml(s).replace(/\//g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render server timings as a row of small tagged pills.
|
||||
* Input is the JSON string as stored in D1, or null.
|
||||
* We keep the tooltip (title attr) with the full `desc` when present
|
||||
* so hovering surfaces readable names without cluttering the table.
|
||||
*/
|
||||
function renderServerTimings(raw) {
|
||||
if (!raw) return '<span style="color:var(--text-dim)">-</span>';
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return '<span style="color:var(--text-dim)">-</span>';
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object") return "";
|
||||
const entries = Object.entries(parsed);
|
||||
if (entries.length === 0) return "";
|
||||
return entries
|
||||
.map(([name, t]) => {
|
||||
const dur = Math.round(t.dur);
|
||||
const title = t.desc ? `${t.desc} (${dur}ms)` : `${name}: ${dur}ms`;
|
||||
return `<span class="timing-tag" title="${escapeAttr(title)}"><strong>${escapeHtml(name)}</strong>${dur}ms</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
configData = await fetchJson("/api/config");
|
||||
|
||||
const siteSelect = document.getElementById("site-select");
|
||||
const sites = configData.sites ?? [];
|
||||
for (const site of sites) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = site.id;
|
||||
opt.textContent = site.label ? `${site.label} (${site.id})` : site.id;
|
||||
if (site.id === configData.defaultSite) opt.selected = true;
|
||||
siteSelect.appendChild(opt);
|
||||
}
|
||||
updateTargetLabel();
|
||||
|
||||
const routeSelect = document.getElementById("route-select");
|
||||
for (const route of configData.routes) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = route.path;
|
||||
opt.textContent = route.label;
|
||||
routeSelect.appendChild(opt);
|
||||
}
|
||||
|
||||
const regionSelect = document.getElementById("region-select");
|
||||
for (const region of configData.regions) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = region.id;
|
||||
opt.textContent = region.label;
|
||||
regionSelect.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
function currentSite() {
|
||||
return document.getElementById("site-select").value || configData?.defaultSite || "blog";
|
||||
}
|
||||
|
||||
function updateTargetLabel() {
|
||||
const site = (configData?.sites ?? []).find((s) => s.id === currentSite());
|
||||
const label = document.getElementById("target-label");
|
||||
if (site?.targetUrl) {
|
||||
label.textContent = site.targetUrl.replace(/^https?:\/\//, "");
|
||||
} else {
|
||||
label.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const data = await fetchJson(`/api/summary?site=${encodeURIComponent(currentSite())}`);
|
||||
const container = document.getElementById("summary-cards");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!data.latest || data.latest.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="summary-card"><div class="label">No data</div><div class="value">-</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by region and show the latest cold TTFB for the selected route
|
||||
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
||||
const routeConfig = configData.routes.find((r) => r.path === route);
|
||||
|
||||
for (const region of configData.regions) {
|
||||
const result = data.latest.find((r) => r.route === route && r.region === region.id);
|
||||
const median = data.medians.find((m) => m.route === route && m.region === region.id);
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "summary-card";
|
||||
|
||||
const coldMs = result?.cold_ttfb_ms;
|
||||
const threshold = routeConfig?.coldThresholdMs ?? 2000;
|
||||
const cls = ttfbClass(coldMs, threshold);
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="label"><span class="region-tag region-${region.id}">${region.id}</span> ${region.label}</div>
|
||||
<div class="value ${cls}">${formatMs(coldMs)}</div>
|
||||
<div class="meta">
|
||||
warm ${formatMs(result?.warm_ttfb_ms)}
|
||||
${median ? ` · avg ${formatMs(median.median_cold)}` : ""}
|
||||
${result?.cf_colo ? ` · ${result.cf_colo}` : ""}
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(card);
|
||||
}
|
||||
|
||||
// Update timestamp
|
||||
const newest = data.latest.reduce(
|
||||
(a, b) => (a.timestamp > b.timestamp ? a : b),
|
||||
data.latest[0],
|
||||
);
|
||||
if (newest) {
|
||||
document.getElementById("last-updated").textContent =
|
||||
`Updated ${formatTime(newest.timestamp)}`;
|
||||
}
|
||||
}
|
||||
|
||||
function createChart(canvasId, label) {
|
||||
const ctx = document.getElementById(canvasId).getContext("2d");
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: "line",
|
||||
data: { datasets: [] },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "nearest", axis: "x", intersect: false },
|
||||
scales: {
|
||||
x: {
|
||||
type: "time",
|
||||
time: { tooltipFormat: "MMM d, HH:mm" },
|
||||
grid: { color: "#2a2a32", lineWidth: 0.5 },
|
||||
ticks: { color: "#5c5c66", font: { size: 10 } },
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: "ms",
|
||||
color: "#5c5c66",
|
||||
font: { size: 10 },
|
||||
},
|
||||
grid: { color: "#2a2a32", lineWidth: 0.5 },
|
||||
ticks: { color: "#5c5c66", font: { size: 10 } },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "#16161a",
|
||||
titleColor: "#e0e0e6",
|
||||
bodyColor: "#888894",
|
||||
borderColor: "#2a2a32",
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 11, family: "monospace" },
|
||||
bodyFont: { size: 11, family: "monospace" },
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const point = context.raw;
|
||||
let text = `${context.dataset.label}: ${formatMs(context.parsed.y)}`;
|
||||
if (point && point.sha) {
|
||||
text += ` [${point.sha.slice(0, 7)}]`;
|
||||
}
|
||||
if (point && point.prNumber) {
|
||||
text += ` PR #${point.prNumber}`;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
},
|
||||
},
|
||||
annotation: { annotations: {} },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
||||
const regionFilter = document.getElementById("region-select").value;
|
||||
const period = document.getElementById("period-select").value;
|
||||
const since = periodToSince(period);
|
||||
|
||||
const regions =
|
||||
regionFilter === "all" ? configData.regions.map((r) => r.id) : [regionFilter];
|
||||
|
||||
const site = currentSite();
|
||||
const bucketDaily = DAILY_BUCKET_PERIODS.has(period);
|
||||
|
||||
// Fetch chart data for each region in parallel
|
||||
const chartDataPromises = regions.map((region) =>
|
||||
fetchJson(
|
||||
`/api/chart?site=${encodeURIComponent(site)}&route=${encodeURIComponent(route)}®ion=${region}&since=${since}&limit=500`,
|
||||
),
|
||||
);
|
||||
const chartResults = await Promise.all(chartDataPromises);
|
||||
|
||||
// Build datasets for cold chart
|
||||
const coldDatasets = [];
|
||||
const warmDatasets = [];
|
||||
const annotations = {};
|
||||
|
||||
for (let i = 0; i < regions.length; i++) {
|
||||
const region = regions[i];
|
||||
const result = chartResults[i];
|
||||
const color = REGION_COLORS[region] || "#888";
|
||||
|
||||
const rawCold = result.data.map((d) => ({
|
||||
x: parseStoredTimestamp(d.timestamp),
|
||||
y: d.coldTtfbMs,
|
||||
prNumber: d.prNumber,
|
||||
sha: d.sha,
|
||||
source: d.source,
|
||||
}));
|
||||
const rawWarm = result.data.map((d) => ({
|
||||
x: parseStoredTimestamp(d.timestamp),
|
||||
y: d.warmTtfbMs,
|
||||
prNumber: d.prNumber,
|
||||
sha: d.sha,
|
||||
source: d.source,
|
||||
}));
|
||||
const coldPoints = bucketDaily ? bucketByUtcDay(rawCold) : rawCold;
|
||||
const warmPoints = bucketDaily ? bucketByUtcDay(rawWarm) : rawWarm;
|
||||
|
||||
coldDatasets.push({
|
||||
label: REGION_LABELS[region] || region,
|
||||
data: coldPoints,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "20",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? 5 : 1.5;
|
||||
},
|
||||
pointBackgroundColor: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? "#ff6b6b" : color;
|
||||
},
|
||||
pointBorderColor: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? "#ff6b6b" : color;
|
||||
},
|
||||
pointBorderWidth: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? 2 : 0;
|
||||
},
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
});
|
||||
|
||||
warmDatasets.push({
|
||||
label: REGION_LABELS[region] || region,
|
||||
data: warmPoints,
|
||||
borderColor: color,
|
||||
backgroundColor: color + "20",
|
||||
borderWidth: 1.5,
|
||||
pointRadius: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? 5 : 1.5;
|
||||
},
|
||||
pointBackgroundColor: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? "#ff6b6b" : color;
|
||||
},
|
||||
pointBorderColor: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? "#ff6b6b" : color;
|
||||
},
|
||||
pointBorderWidth: (ctx) => {
|
||||
const point = ctx.raw;
|
||||
return point && point.sha ? 2 : 0;
|
||||
},
|
||||
tension: 0.3,
|
||||
fill: false,
|
||||
});
|
||||
|
||||
// Add vertical lines for all deploys
|
||||
if (result.deployMarkers) {
|
||||
for (const marker of result.deployMarkers) {
|
||||
const sha7 = marker.sha ? marker.sha.slice(0, 7) : "?";
|
||||
const label = marker.prNumber ? `PR #${marker.prNumber}` : sha7;
|
||||
const key = `deploy-${marker.sha || marker.timestamp}-${region}`;
|
||||
annotations[key] = {
|
||||
type: "line",
|
||||
xMin: parseStoredTimestamp(marker.timestamp),
|
||||
xMax: parseStoredTimestamp(marker.timestamp),
|
||||
borderColor: "#ff6b6b40",
|
||||
borderWidth: 1,
|
||||
borderDash: [4, 4],
|
||||
label: {
|
||||
display: regions.length <= 2,
|
||||
content: label,
|
||||
position: "start",
|
||||
backgroundColor: "#2a1a1a",
|
||||
color: "#ff6b6b",
|
||||
font: { size: 10, family: "monospace" },
|
||||
padding: { top: 2, bottom: 2, left: 4, right: 4 },
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update cold chart
|
||||
if (!coldChart) {
|
||||
coldChart = createChart("cold-chart", "Cold TTFB");
|
||||
}
|
||||
coldChart.data.datasets = coldDatasets;
|
||||
coldChart.options.plugins.annotation.annotations = annotations;
|
||||
coldChart.update();
|
||||
|
||||
// Update warm chart
|
||||
if (!warmChart) {
|
||||
warmChart = createChart("warm-chart", "Warm TTFB");
|
||||
}
|
||||
warmChart.data.datasets = warmDatasets;
|
||||
warmChart.options.plugins.annotation.annotations = annotations;
|
||||
warmChart.update();
|
||||
}
|
||||
|
||||
async function loadTable() {
|
||||
const route = document.getElementById("route-select").value || configData.routes[0]?.path;
|
||||
const regionFilter = document.getElementById("region-select").value;
|
||||
const period = document.getElementById("period-select").value;
|
||||
const since = periodToSince(period);
|
||||
|
||||
const params = new URLSearchParams({
|
||||
site: currentSite(),
|
||||
since,
|
||||
limit: "50",
|
||||
});
|
||||
if (route) params.set("route", route);
|
||||
if (regionFilter !== "all") params.set("region", regionFilter);
|
||||
|
||||
const data = await fetchJson(`/api/results?${params}`);
|
||||
const tbody = document.getElementById("results-body");
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="11" style="text-align:center;color:var(--text-dim)">No results</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = data.results
|
||||
.map((r) => {
|
||||
const routeConfig = configData.routes.find((rc) => rc.path === r.route);
|
||||
const threshold = routeConfig?.coldThresholdMs ?? 2000;
|
||||
const cls = ttfbClass(r.cold_ttfb_ms, threshold);
|
||||
|
||||
const prLink = r.pr_number
|
||||
? ` <a class="pr-badge" href="${GITHUB_URL}/pull/${r.pr_number}" target="_blank" rel="noopener">PR #${r.pr_number}</a>`
|
||||
: "";
|
||||
const shaLink = r.sha
|
||||
? ` <a class="sha-link mono" href="${GITHUB_URL}/commit/${r.sha}" target="_blank" rel="noopener">${r.sha.slice(0, 7)}</a>`
|
||||
: "";
|
||||
const sourceBadge = `<span class="source-badge source-${escapeAttr(r.source)}">${escapeHtml(r.source)}</span>`;
|
||||
const note = r.note
|
||||
? `<div class="note-text" title="${escapeAttr(r.note)}">${escapeHtml(r.note)}</div>`
|
||||
: "";
|
||||
|
||||
return `<tr>
|
||||
<td class="mono">${formatTime(r.timestamp)}</td>
|
||||
<td>${escapeHtml(r.route)}${note}</td>
|
||||
<td><span class="region-tag region-${escapeAttr(r.region)}">${escapeHtml(r.region)}</span></td>
|
||||
<td class="mono ${cls}">${formatMs(r.cold_ttfb_ms)}</td>
|
||||
<td class="mono">${formatMs(r.warm_ttfb_ms)}</td>
|
||||
<td class="mono">${formatMs(r.p95_ttfb_ms)}</td>
|
||||
<td class="mono">${r.status_code ?? "-"}</td>
|
||||
<td class="mono">${r.cf_colo ?? "-"}</td>
|
||||
<td>${renderServerTimings(r.cold_server_timings)}</td>
|
||||
<td>${renderServerTimings(r.warm_server_timings)}</td>
|
||||
<td>${sourceBadge}${prLink}${shaLink}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
await Promise.all([loadSummary(), loadCharts(), loadTable()]);
|
||||
} catch (err) {
|
||||
console.error("Failed to load data:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
await loadConfig();
|
||||
await refresh();
|
||||
|
||||
// Refresh on control changes
|
||||
document.getElementById("site-select").addEventListener("change", () => {
|
||||
updateTargetLabel();
|
||||
refresh();
|
||||
});
|
||||
document.getElementById("route-select").addEventListener("change", refresh);
|
||||
document.getElementById("region-select").addEventListener("change", refresh);
|
||||
document.getElementById("period-select").addEventListener("change", refresh);
|
||||
|
||||
// Auto-refresh every 5 minutes
|
||||
setInterval(refresh, 5 * 60 * 1000);
|
||||
} catch (err) {
|
||||
document.querySelector(".container").innerHTML = `
|
||||
<div class="error-msg">Failed to load: ${err.message}</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
infra/perf-monitor/schema.sql
Normal file
26
infra/perf-monitor/schema.sql
Normal file
@@ -0,0 +1,26 @@
|
||||
-- Perf monitor D1 schema
|
||||
|
||||
CREATE TABLE IF NOT EXISTS perf_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
sha TEXT,
|
||||
pr_number INTEGER,
|
||||
route TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
cold_ttfb_ms REAL,
|
||||
warm_ttfb_ms REAL,
|
||||
p95_ttfb_ms REAL,
|
||||
status_code INTEGER,
|
||||
cf_colo TEXT,
|
||||
cf_placement TEXT,
|
||||
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
source TEXT NOT NULL, -- 'deploy' | 'cron' | 'manual'
|
||||
site TEXT NOT NULL DEFAULT 'blog'
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_route_region_ts ON perf_results(route, region, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_sha ON perf_results(sha);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_pr ON perf_results(pr_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_source_ts ON perf_results(source, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_timestamp ON perf_results(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_site_ts ON perf_results(site, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_perf_site_route_region_ts ON perf_results(site, route, region, timestamp);
|
||||
243
infra/perf-monitor/scripts/trigger.mjs
Executable file
243
infra/perf-monitor/scripts/trigger.mjs
Executable file
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ad-hoc perf-monitor trigger.
|
||||
*
|
||||
* Fires POST https://perf.emdashcms.com/api/trigger via Cloudflare Access.
|
||||
* The endpoint is gated by Access, so authentication is handled by
|
||||
* `cloudflared access` (first invocation opens a browser; subsequent
|
||||
* invocations reuse the token until session expiry).
|
||||
*
|
||||
* Usage:
|
||||
* pnpm trigger # default: runs probes, does NOT record
|
||||
* pnpm trigger -- --store # persist with source=manual
|
||||
* pnpm trigger -- --store --note "..." # persist with a note
|
||||
* pnpm trigger -- --sha abc1234 # attach a SHA (requires --store to persist)
|
||||
* pnpm trigger -- --pr 123 # attach a PR number (requires --store)
|
||||
* pnpm trigger -- --site cache # measure only the cache-demo site
|
||||
*
|
||||
* The default is ephemeral -- probes run for real but nothing is written
|
||||
* to the database. Pass --store to persist the run as source=manual
|
||||
* (excluded from the graph and summary cards, visible in the results
|
||||
* table). Other flags like --note/--sha/--pr only have an effect when
|
||||
* combined with --store.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
const ENDPOINT = process.env.PERF_ENDPOINT ?? "https://perf.emdashcms.com/api/trigger";
|
||||
|
||||
function die(msg, code = 1) {
|
||||
console.error(`trigger: ${msg}`);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
// pnpm passes a literal `--` token through when users invoke `pnpm trigger -- --note foo`.
|
||||
// parseArgs treats that as a positional and throws in strict mode. Strip any `--` tokens.
|
||||
const argv = process.argv.slice(2).filter((a) => a !== "--");
|
||||
|
||||
if (argv.includes("-h") || argv.includes("--help")) {
|
||||
console.log(
|
||||
"Usage: pnpm trigger [-- --store] [--note <string>] [--sha <sha>] [--pr <number>] [--site <id>]\n" +
|
||||
"\n" +
|
||||
"Runs an ad-hoc perf measurement against every registered demo site.\n" +
|
||||
"Pass --site <id> (e.g. blog, cache) to target a single site.\n" +
|
||||
"\n" +
|
||||
"Default is ephemeral: probes run for real but nothing is written to\n" +
|
||||
"the database. Pass --store to persist the run as source=manual\n" +
|
||||
"(excluded from the graph and summary cards, visible in the results\n" +
|
||||
"table). --note/--sha/--pr only take effect together with --store.",
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
store: { type: "boolean" },
|
||||
note: { type: "string" },
|
||||
sha: { type: "string" },
|
||||
pr: { type: "string" },
|
||||
site: { type: "string" },
|
||||
},
|
||||
allowPositionals: false,
|
||||
strict: true,
|
||||
});
|
||||
|
||||
// Default is ephemeral (not persisted). --store flips that.
|
||||
const ephemeral = !values.store;
|
||||
|
||||
const body = {};
|
||||
if (ephemeral) body.ephemeral = true;
|
||||
if (values.note) body.note = values.note;
|
||||
if (values.sha) body.sha = values.sha;
|
||||
if (values.pr) {
|
||||
const n = Number.parseInt(values.pr, 10);
|
||||
if (!Number.isInteger(n) || n <= 0) die(`--pr must be a positive integer, got ${values.pr}`);
|
||||
body.prNumber = n;
|
||||
}
|
||||
if (values.site) body.site = values.site;
|
||||
|
||||
// Warn loudly if someone passed metadata flags without --store: those fields
|
||||
// only make it into the DB, and we're not writing to the DB in ephemeral mode.
|
||||
if (ephemeral && (values.note || values.sha || values.pr)) {
|
||||
console.warn(
|
||||
"trigger: warning: --note/--sha/--pr have no effect without --store (ephemeral mode discards everything)",
|
||||
);
|
||||
}
|
||||
|
||||
const label = values.note ? ` (${values.note})` : "";
|
||||
const mode = ephemeral ? " [ephemeral, not recorded]" : "";
|
||||
console.log(`trigger: firing against ${ENDPOINT}${label}${mode}`);
|
||||
console.log("trigger: this typically takes 20-40s while probes run...");
|
||||
|
||||
// `cloudflared access curl` passes everything after the URL straight to curl.
|
||||
// The URL must come first, immediately after `curl` (no `--` separator).
|
||||
const result = spawnSync(
|
||||
"cloudflared",
|
||||
[
|
||||
"access",
|
||||
"curl",
|
||||
ENDPOINT,
|
||||
"-sS",
|
||||
"-X",
|
||||
"POST",
|
||||
"-H",
|
||||
"content-type: application/json",
|
||||
"--data",
|
||||
JSON.stringify(body),
|
||||
],
|
||||
{ encoding: "utf8", stdio: ["inherit", "pipe", "inherit"] },
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.code === "ENOENT") {
|
||||
die(
|
||||
"cloudflared is not installed or not on PATH.\n" +
|
||||
" Install: brew install cloudflared (or see https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/)\n" +
|
||||
" Then run this command again to complete first-time browser login.",
|
||||
);
|
||||
}
|
||||
die(`cloudflared failed: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) die(`cloudflared exited ${result.status}`);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout);
|
||||
} catch {
|
||||
die(`unexpected non-JSON response:\n${result.stdout}`);
|
||||
}
|
||||
|
||||
if (parsed.error) die(`server error: ${parsed.error}`);
|
||||
|
||||
if (parsed.ephemeral) {
|
||||
console.log(
|
||||
`trigger: measured ${parsed.results?.length ?? 0} samples in ${parsed.durationMs}ms (ephemeral, nothing recorded)`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`trigger: recorded ${parsed.inserted} samples in ${parsed.durationMs}ms (source=manual)`,
|
||||
);
|
||||
}
|
||||
|
||||
// Pretty-print a per-site, per-route table. Layout per (site, route):
|
||||
//
|
||||
// [cache] /
|
||||
// REGION COLD WARM P95 COLO TIMINGS
|
||||
// use 1234ms 123ms 156ms IAD render=42ms mw=58ms
|
||||
// euw ...
|
||||
//
|
||||
// Column widths are computed from the rows we're about to print so that
|
||||
// unusually slow runs don't break alignment, and timing columns only
|
||||
// appear if at least one row has timings.
|
||||
const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
|
||||
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
|
||||
const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);
|
||||
|
||||
const formatMs = (n) => (n == null ? "-" : `${Math.round(n)}ms`);
|
||||
|
||||
// Group results by site -> route, preserving insertion order. Results from
|
||||
// the server arrive interleaved per (site, region, route); a Map-of-Maps
|
||||
// keeps each site's rows together for display.
|
||||
const bySite = new Map();
|
||||
for (const r of parsed.results ?? []) {
|
||||
const siteId = r.site ?? "blog";
|
||||
if (!bySite.has(siteId)) bySite.set(siteId, new Map());
|
||||
const byRoute = bySite.get(siteId);
|
||||
if (!byRoute.has(r.route)) byRoute.set(r.route, []);
|
||||
byRoute.get(r.route).push(r);
|
||||
}
|
||||
|
||||
for (const [siteId, byRoute] of bySite) {
|
||||
for (const [route, rows] of byRoute) {
|
||||
console.log(`\n ${bold(`[${siteId}] ${route}`)}`);
|
||||
|
||||
// Collect the union of timing names present on this route across BOTH
|
||||
// cold and warm snapshots so every row gets a cell in each column,
|
||||
// even when a particular probe response lacked some entries.
|
||||
// Warm timings are prefixed with "w." in the column header to make
|
||||
// the split obvious (cold and warm snapshots share the same metric
|
||||
// names — "render", "rt", "mw" — so we'd collide otherwise).
|
||||
const coldNames = [];
|
||||
const warmNames = [];
|
||||
const seenCold = new Set();
|
||||
const seenWarm = new Set();
|
||||
for (const r of rows) {
|
||||
if (r.coldServerTimings) {
|
||||
for (const name of Object.keys(r.coldServerTimings)) {
|
||||
if (!seenCold.has(name)) {
|
||||
seenCold.add(name);
|
||||
coldNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.warmServerTimings) {
|
||||
for (const name of Object.keys(r.warmServerTimings)) {
|
||||
if (!seenWarm.has(name)) {
|
||||
seenWarm.add(name);
|
||||
warmNames.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build row cells. Column order: region, cold, warm, p95, colo,
|
||||
// then all cold timings (cold-* intent), then warm timings.
|
||||
// Cold timings keep their bare names for backwards-compatible output;
|
||||
// warm timings get a "w." prefix.
|
||||
const warmHeaders = warmNames.map((n) => `w.${n}`);
|
||||
const header = ["region", "cold", "warm", "p95", "colo", ...coldNames, ...warmHeaders];
|
||||
const tableRows = rows.map((r) => {
|
||||
const cells = [
|
||||
r.region,
|
||||
formatMs(r.coldTtfbMs),
|
||||
formatMs(r.warmTtfbMs),
|
||||
formatMs(r.p95TtfbMs),
|
||||
r.cfColo ?? "-",
|
||||
];
|
||||
for (const name of coldNames) {
|
||||
const t = r.coldServerTimings?.[name];
|
||||
cells.push(t ? formatMs(t.dur) : "-");
|
||||
}
|
||||
for (const name of warmNames) {
|
||||
const t = r.warmServerTimings?.[name];
|
||||
cells.push(t ? formatMs(t.dur) : "-");
|
||||
}
|
||||
return cells;
|
||||
});
|
||||
|
||||
// Column widths = max(header, body) per column.
|
||||
const widths = header.map((h, col) =>
|
||||
Math.max(h.length, ...tableRows.map((cells) => cells[col].length)),
|
||||
);
|
||||
|
||||
const padCell = (s, col) => s.padEnd(widths[col]);
|
||||
const joinRow = (cells) => cells.map(padCell).join(" ");
|
||||
|
||||
console.log(` ${dim(joinRow(header))}`);
|
||||
for (const cells of tableRows) {
|
||||
console.log(` ${joinRow(cells)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
264
infra/perf-monitor/src/api.ts
Normal file
264
infra/perf-monitor/src/api.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/** HTTP API router for the perf monitor. */
|
||||
|
||||
import { runMeasurements } from "./measure.js";
|
||||
import {
|
||||
DEFAULT_SITE_ID,
|
||||
getSite,
|
||||
REGIONS,
|
||||
REGION_LABELS,
|
||||
SITES,
|
||||
TARGET_ROUTES,
|
||||
} from "./routes.js";
|
||||
import {
|
||||
queryResults,
|
||||
getLatestResults,
|
||||
getRollingMedians,
|
||||
getDeployResults,
|
||||
insertResults,
|
||||
type Source,
|
||||
} from "./store.js";
|
||||
|
||||
/** Route the request to the correct handler. */
|
||||
export async function handleApi(request: Request, url: URL, env: Env): Promise<Response | null> {
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/results" && request.method === "GET") {
|
||||
return handleResults(url, env);
|
||||
}
|
||||
if (path === "/api/summary" && request.method === "GET") {
|
||||
return handleSummary(url, env);
|
||||
}
|
||||
if (path === "/api/chart" && request.method === "GET") {
|
||||
return handleChart(url, env);
|
||||
}
|
||||
if (path === "/api/config" && request.method === "GET") {
|
||||
return handleConfig();
|
||||
}
|
||||
if (path === "/api/trigger" && request.method === "POST") {
|
||||
return handleTrigger(request, env);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Narrow a query string to the allowed source values without a cast. */
|
||||
function parseSource(raw: string | null): Source | undefined {
|
||||
if (raw === "deploy" || raw === "cron" || raw === "manual") return raw;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the requested site param against the known SITES list. Falls back
|
||||
* to the default site when absent so existing clients (dashboard) keep
|
||||
* working unchanged.
|
||||
*/
|
||||
function parseSiteParam(raw: string | null): string {
|
||||
if (raw && getSite(raw)) return raw;
|
||||
return DEFAULT_SITE_ID;
|
||||
}
|
||||
|
||||
/** GET /api/results?route=X®ion=Y&source=Z&site=W&since=ISO&limit=N */
|
||||
async function handleResults(url: URL, env: Env): Promise<Response> {
|
||||
const source = parseSource(url.searchParams.get("source"));
|
||||
const siteParam = url.searchParams.get("site");
|
||||
// Results is intentionally loose: no site param = return across all sites
|
||||
// (for raw tabular inspection). Summary/chart default to a single site.
|
||||
const site = siteParam && getSite(siteParam) ? siteParam : undefined;
|
||||
|
||||
const results = await queryResults(env.DB, {
|
||||
route: url.searchParams.get("route") ?? undefined,
|
||||
region: url.searchParams.get("region") ?? undefined,
|
||||
source,
|
||||
site,
|
||||
since: url.searchParams.get("since") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? parseInt(url.searchParams.get("limit")!, 10) : undefined,
|
||||
});
|
||||
|
||||
return Response.json({ results });
|
||||
}
|
||||
|
||||
/** GET /api/summary?site=X -- latest per route+region, rolling averages */
|
||||
async function handleSummary(url: URL, env: Env): Promise<Response> {
|
||||
const site = parseSiteParam(url.searchParams.get("site"));
|
||||
|
||||
const [latest, medians] = await Promise.all([
|
||||
getLatestResults(env.DB, site),
|
||||
getRollingMedians(env.DB, site),
|
||||
]);
|
||||
|
||||
return Response.json({
|
||||
site,
|
||||
latest,
|
||||
medians,
|
||||
config: {
|
||||
sites: SITES.map((s) => ({ id: s.id, label: s.label, targetUrl: s.targetUrl })),
|
||||
routes: TARGET_ROUTES,
|
||||
regions: REGIONS.map((r) => ({ id: r, label: REGION_LABELS[r] })),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/chart?route=X®ion=Y&site=W&since=ISO&limit=N -- time series data */
|
||||
async function handleChart(url: URL, env: Env): Promise<Response> {
|
||||
const route = url.searchParams.get("route");
|
||||
const region = url.searchParams.get("region");
|
||||
|
||||
if (!route || !region) {
|
||||
return Response.json({ error: "route and region are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const site = parseSiteParam(url.searchParams.get("site"));
|
||||
const since = url.searchParams.get("since") ?? undefined;
|
||||
const limit = url.searchParams.has("limit") ? parseInt(url.searchParams.get("limit")!, 10) : 200;
|
||||
|
||||
const [results, deployResults] = await Promise.all([
|
||||
queryResults(env.DB, { route, region, site, since, limit }),
|
||||
getDeployResults(env.DB, site, since),
|
||||
]);
|
||||
|
||||
// Query returns DESC -- reverse to chronological. Manual (ad-hoc) runs are
|
||||
// stripped from the graph so they don't create visual noise; they still
|
||||
// appear in the /api/results table.
|
||||
const graphResults = results.filter((r) => r.source !== "manual").toReversed();
|
||||
|
||||
// Deduplicate deploy results by SHA — multiple route/region combos produce
|
||||
// duplicates, but we only want one marker per deploy on the chart.
|
||||
const seenShas = new Set<string>();
|
||||
const deployMarkers = deployResults
|
||||
.filter((r) => {
|
||||
if (!r.sha) return false;
|
||||
if (r.route !== route || r.region !== region) return false;
|
||||
if (seenShas.has(r.sha)) return false;
|
||||
seenShas.add(r.sha);
|
||||
return true;
|
||||
})
|
||||
.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
prNumber: r.pr_number,
|
||||
sha: r.sha,
|
||||
coldTtfbMs: r.cold_ttfb_ms,
|
||||
}));
|
||||
|
||||
return Response.json({
|
||||
route,
|
||||
region,
|
||||
site,
|
||||
data: graphResults.map((r) => ({
|
||||
timestamp: r.timestamp,
|
||||
coldTtfbMs: r.cold_ttfb_ms,
|
||||
warmTtfbMs: r.warm_ttfb_ms,
|
||||
p95TtfbMs: r.p95_ttfb_ms,
|
||||
source: r.source,
|
||||
sha: r.sha,
|
||||
prNumber: r.pr_number,
|
||||
})),
|
||||
deployMarkers,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /api/config -- available sites, routes, and regions */
|
||||
async function handleConfig(): Promise<Response> {
|
||||
return Response.json({
|
||||
sites: SITES.map((s) => ({ id: s.id, label: s.label, targetUrl: s.targetUrl })),
|
||||
defaultSite: DEFAULT_SITE_ID,
|
||||
routes: TARGET_ROUTES,
|
||||
regions: REGIONS.map((r) => ({ id: r, label: REGION_LABELS[r] })),
|
||||
});
|
||||
}
|
||||
|
||||
/** Accept short abbreviated or full-length hex SHAs. */
|
||||
const SHA_RE = /^[a-f0-9]{7,40}$/i;
|
||||
|
||||
/**
|
||||
* POST /api/trigger -- run an ad-hoc measurement, optionally record it.
|
||||
*
|
||||
* Body (all optional):
|
||||
* {
|
||||
* "note"?: string,
|
||||
* "sha"?: string,
|
||||
* "prNumber"?: number,
|
||||
* "ephemeral"?: boolean, // if true, run the probes but don't persist
|
||||
* "site"?: string // site id; omit to measure every site
|
||||
* }
|
||||
*
|
||||
* No auth in-Worker: this endpoint is expected to be protected by a
|
||||
* Cloudflare Access policy at the edge. If Access misroutes or is
|
||||
* misconfigured, the request will still run measurements -- keep Access
|
||||
* scoped tightly to POST /api/trigger.
|
||||
*
|
||||
* Persisted runs are tagged source=manual and are excluded from the
|
||||
* dashboard graph and summary cards but appear in the results table with
|
||||
* a "manual" badge. Ephemeral runs run the probes for real but skip the
|
||||
* insert entirely -- useful for private/local checks that shouldn't
|
||||
* appear on the dashboard at all.
|
||||
*/
|
||||
async function handleTrigger(request: Request, env: Env): Promise<Response> {
|
||||
let body: {
|
||||
note?: unknown;
|
||||
sha?: unknown;
|
||||
prNumber?: unknown;
|
||||
ephemeral?: unknown;
|
||||
site?: unknown;
|
||||
} = {};
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength && contentLength !== "0") {
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return Response.json({ error: "invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const note = typeof body.note === "string" && body.note.trim() !== "" ? body.note.trim() : null;
|
||||
const sha = typeof body.sha === "string" && SHA_RE.test(body.sha) ? body.sha : null;
|
||||
const prNumber =
|
||||
typeof body.prNumber === "number" && Number.isInteger(body.prNumber) && body.prNumber > 0
|
||||
? body.prNumber
|
||||
: null;
|
||||
const ephemeral = body.ephemeral === true;
|
||||
|
||||
let sites = SITES;
|
||||
if (typeof body.site === "string") {
|
||||
const match = getSite(body.site);
|
||||
if (!match) {
|
||||
return Response.json(
|
||||
{ error: `unknown site "${body.site}"; valid: ${SITES.map((s) => s.id).join(", ")}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
sites = [match];
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
const results = await runMeasurements(env, { source: "manual", sha, prNumber, note, sites });
|
||||
|
||||
if (results.length === 0) {
|
||||
return Response.json({ error: "no measurements returned from probes" }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!ephemeral) {
|
||||
await insertResults(env.DB, results);
|
||||
}
|
||||
|
||||
return Response.json({
|
||||
inserted: ephemeral ? 0 : results.length,
|
||||
ephemeral,
|
||||
durationMs: Date.now() - started,
|
||||
note,
|
||||
sha,
|
||||
prNumber,
|
||||
sites: sites.map((s) => s.id),
|
||||
// Echo the structured result so the CLI can print it without a follow-up query.
|
||||
results: results.map((r) => ({
|
||||
site: r.site,
|
||||
route: r.route,
|
||||
region: r.region,
|
||||
coldTtfbMs: r.coldTtfbMs,
|
||||
warmTtfbMs: r.warmTtfbMs,
|
||||
p95TtfbMs: r.p95TtfbMs,
|
||||
cfColo: r.cfColo,
|
||||
coldServerTimings: r.coldServerTimings,
|
||||
warmServerTimings: r.warmServerTimings,
|
||||
})),
|
||||
});
|
||||
}
|
||||
59
infra/perf-monitor/src/events.ts
Normal file
59
infra/perf-monitor/src/events.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Type definitions for Cloudflare event subscription messages.
|
||||
* See: https://developers.cloudflare.com/queues/event-subscriptions/events-schemas/
|
||||
*/
|
||||
|
||||
/** Workers Builds `build.succeeded` event. */
|
||||
export interface BuildSucceededEvent {
|
||||
type: "cf.workersBuilds.worker.build.succeeded";
|
||||
source: {
|
||||
type: "workersBuilds.worker";
|
||||
workerName: string;
|
||||
};
|
||||
payload: {
|
||||
buildUuid: string;
|
||||
status: "success";
|
||||
buildOutcome: "success";
|
||||
createdAt: string;
|
||||
initializingAt: string;
|
||||
runningAt: string;
|
||||
stoppedAt: string;
|
||||
buildTriggerMetadata: {
|
||||
buildTriggerSource: string;
|
||||
branch: string;
|
||||
commitHash: string;
|
||||
commitMessage: string;
|
||||
author: string;
|
||||
buildCommand: string;
|
||||
deployCommand: string;
|
||||
rootDirectory: string;
|
||||
repoName: string;
|
||||
providerAccountName: string;
|
||||
providerType: string;
|
||||
};
|
||||
};
|
||||
metadata: {
|
||||
accountId: string;
|
||||
eventSubscriptionId: string;
|
||||
eventSchemaVersion: number;
|
||||
eventTimestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Other event types we may receive from the subscription but ignore.
|
||||
* Kept loose (string `type`) so we don't block on schema updates.
|
||||
*/
|
||||
export interface UnknownEvent {
|
||||
type: string;
|
||||
source?: unknown;
|
||||
payload?: unknown;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
export type PerfQueueMessage = BuildSucceededEvent | UnknownEvent;
|
||||
|
||||
/** Type guard for the only event we actually act on. */
|
||||
export function isBuildSucceeded(event: PerfQueueMessage): event is BuildSucceededEvent {
|
||||
return event.type === "cf.workersBuilds.worker.build.succeeded";
|
||||
}
|
||||
77
infra/perf-monitor/src/github.ts
Normal file
77
infra/perf-monitor/src/github.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* GitHub API helpers for resolving a commit SHA to a merged PR number.
|
||||
*
|
||||
* Uses the "list pull requests associated with a commit" endpoint:
|
||||
* https://docs.github.com/en/rest/commits/commits#list-pull-requests-associated-with-a-commit
|
||||
*
|
||||
* Called unauthenticated. The public repo endpoint has a 60 req/hr limit per IP,
|
||||
* which is far more than our deploy rate. If that ever changes, add a token:
|
||||
* `headers.authorization = "Bearer " + env.GITHUB_TOKEN`.
|
||||
*/
|
||||
|
||||
import { GITHUB_REPO } from "./routes.js";
|
||||
|
||||
interface AssociatedPR {
|
||||
number: number;
|
||||
state: string;
|
||||
merged_at: string | null;
|
||||
base: { ref: string };
|
||||
}
|
||||
const PR_NUMBER_REGEX = /\(#(\d+)\)\s*$/;
|
||||
/**
|
||||
* Parse a PR number from a commit message. GitHub squash merges append the PR
|
||||
* number in parentheses, e.g. "feat: add feature (#123)".
|
||||
*/
|
||||
function parsePrFromMessage(commitMessage: string): number | null {
|
||||
const match = commitMessage.match(PR_NUMBER_REGEX);
|
||||
if (!match?.[1]) return null;
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the merged PR for a given commit SHA, if any.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Parse the commit message for `(#N)` — works for squash merges (the common case).
|
||||
* 2. Fall back to the GitHub "list PRs for a commit" API — works for merge commits.
|
||||
*
|
||||
* Returns null if no PR exists (e.g. direct push to main) or the lookup fails.
|
||||
*/
|
||||
export async function resolvePrForSha(sha: string, commitMessage?: string): Promise<number | null> {
|
||||
if (commitMessage) {
|
||||
const fromMessage = parsePrFromMessage(commitMessage);
|
||||
if (fromMessage) return fromMessage;
|
||||
}
|
||||
|
||||
const url = `https://api.github.com/repos/${GITHUB_REPO}/commits/${sha}/pulls`;
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
headers: {
|
||||
accept: "application/vnd.github+json",
|
||||
"user-agent": "emdash-perf-monitor",
|
||||
"x-github-api-version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("PR lookup failed:", err);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(`PR lookup for ${sha} returned ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const prs = await response.json<AssociatedPR[]>();
|
||||
|
||||
// Prefer a merged PR targeting main. Fall back to any merged PR.
|
||||
const mainPr = prs.find((p) => p.merged_at && p.base.ref === "main");
|
||||
if (mainPr) return mainPr.number;
|
||||
|
||||
const anyMerged = prs.find((p) => p.merged_at);
|
||||
if (anyMerged) return anyMerged.number;
|
||||
|
||||
return null;
|
||||
}
|
||||
119
infra/perf-monitor/src/index.ts
Normal file
119
infra/perf-monitor/src/index.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Perf monitor coordinator Worker.
|
||||
*
|
||||
* Triggers:
|
||||
* - Queue consumer: fires on every `build.succeeded` event from Cloudflare's event
|
||||
* subscriptions. We filter for the demo Worker and run measurements tagged with
|
||||
* the deploy's commit SHA. This is the primary deploy-attribution path.
|
||||
* - Cron (every 30 min): ambient baseline. Runs untagged; fills gaps between deploys
|
||||
* and catches drift the queue might miss (subscription downtime, DLQ, etc).
|
||||
* - POST /api/trigger: ad-hoc manual measurement, tagged `source=manual`.
|
||||
* Expected to be protected by a Cloudflare Access policy at the edge.
|
||||
*
|
||||
* HTTP endpoints other than /api/trigger are read-only: JSON API at /api/* and
|
||||
* the static dashboard at /.
|
||||
*/
|
||||
|
||||
import { handleApi } from "./api.js";
|
||||
import type { PerfQueueMessage } from "./events.js";
|
||||
import { isBuildSucceeded } from "./events.js";
|
||||
import { resolvePrForSha } from "./github.js";
|
||||
import { runMeasurements } from "./measure.js";
|
||||
import { TRIGGER_WORKER_NAME } from "./routes.js";
|
||||
import { insertResults } from "./store.js";
|
||||
|
||||
/**
|
||||
* Handle a single build-succeeded event: filter for the demo Worker, resolve
|
||||
* the PR number via GitHub, run measurements, persist. Errors are swallowed
|
||||
* so one bad message doesn't poison the batch.
|
||||
*/
|
||||
async function handleBuildSucceeded(
|
||||
env: Env,
|
||||
event: Extract<PerfQueueMessage, { type: "cf.workersBuilds.worker.build.succeeded" }>,
|
||||
): Promise<void> {
|
||||
const workerName = event.source.workerName;
|
||||
if (workerName !== TRIGGER_WORKER_NAME) {
|
||||
// Not our trigger worker -- ignore. Both demos build from the same
|
||||
// commit, so one event covers both sites; measuring on every known
|
||||
// worker's event would double our load without adding signal.
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = event.payload.buildTriggerMetadata;
|
||||
if (meta.branch !== "main") {
|
||||
// Only measure main-branch deploys.
|
||||
return;
|
||||
}
|
||||
|
||||
const sha = meta.commitHash;
|
||||
if (!sha) {
|
||||
console.warn("build.succeeded event missing commitHash; skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Running deploy-triggered measurement for ${workerName} @ ${sha.slice(0, 7)}`);
|
||||
|
||||
const prNumber = await resolvePrForSha(sha, meta.commitMessage);
|
||||
const results = await runMeasurements(env, { source: "deploy", sha, prNumber });
|
||||
|
||||
if (results.length > 0) {
|
||||
await insertResults(env.DB, results);
|
||||
console.log(
|
||||
`Stored ${results.length} deploy measurements for ${sha.slice(0, 7)}${prNumber ? ` (PR #${prNumber})` : ""}`,
|
||||
);
|
||||
} else {
|
||||
console.warn(`No measurements returned for ${sha.slice(0, 7)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
const apiResponse = await handleApi(request, url, env);
|
||||
if (apiResponse) return apiResponse;
|
||||
|
||||
// Anything else falls through to Workers Assets for the dashboard.
|
||||
return new Response("Not found", { status: 404 });
|
||||
},
|
||||
|
||||
async scheduled(
|
||||
controller: ScheduledController,
|
||||
env: Env,
|
||||
_ctx: ExecutionContext,
|
||||
): Promise<void> {
|
||||
console.log(`Cron triggered at ${new Date(controller.scheduledTime).toISOString()}`);
|
||||
|
||||
const results = await runMeasurements(env, { source: "cron" });
|
||||
|
||||
if (results.length > 0) {
|
||||
await insertResults(env.DB, results);
|
||||
console.log(`Stored ${results.length} cron measurements`);
|
||||
} else {
|
||||
console.warn("No measurements returned from probes");
|
||||
}
|
||||
},
|
||||
|
||||
async queue(batch: MessageBatch<PerfQueueMessage>, env: Env): Promise<void> {
|
||||
// Messages are processed sequentially to avoid hammering the demo with
|
||||
// parallel measurement runs (each one issues N requests per region).
|
||||
// A batch of deploy events for different Workers is rare but possible.
|
||||
for (const message of batch.messages) {
|
||||
try {
|
||||
const event = message.body;
|
||||
if (!isBuildSucceeded(event)) {
|
||||
// Event type we don't care about (build.started, build.failed, etc).
|
||||
// Ack silently.
|
||||
message.ack();
|
||||
continue;
|
||||
}
|
||||
await handleBuildSucceeded(env, event);
|
||||
message.ack();
|
||||
} catch (err) {
|
||||
console.error("Failed to process queue message:", err);
|
||||
// Retry -- exhausted retries send to the DLQ configured in wrangler.jsonc.
|
||||
message.retry();
|
||||
}
|
||||
}
|
||||
},
|
||||
} satisfies ExportedHandler<Env, PerfQueueMessage>;
|
||||
101
infra/perf-monitor/src/measure.ts
Normal file
101
infra/perf-monitor/src/measure.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/** Orchestrates a measurement run across all regional probes. */
|
||||
|
||||
import type { MeasureResponse } from "../probe/src/measure.js";
|
||||
import { REGIONS, SITES, TARGET_ROUTES, WARM_REQUESTS } from "./routes.js";
|
||||
import type { Region, Site } from "./routes.js";
|
||||
import type { InsertParams, Source } from "./store.js";
|
||||
|
||||
const PROBE_BINDINGS: Record<
|
||||
Region,
|
||||
keyof Pick<Env, "PROBE_USE" | "PROBE_EUW" | "PROBE_APE" | "PROBE_APS">
|
||||
> = {
|
||||
use: "PROBE_USE",
|
||||
euw: "PROBE_EUW",
|
||||
ape: "PROBE_APE",
|
||||
aps: "PROBE_APS",
|
||||
};
|
||||
|
||||
function generateId(): string {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/** Options for {@link runMeasurements} beyond the source tag. */
|
||||
export interface RunOptions {
|
||||
source: Source;
|
||||
sha?: string | null;
|
||||
prNumber?: number | null;
|
||||
note?: string | null;
|
||||
/**
|
||||
* Sites to measure. Defaults to every site in {@link SITES}. Pass a subset
|
||||
* when a caller wants to target only one deployment (e.g. manual triggers).
|
||||
*/
|
||||
sites?: readonly Site[];
|
||||
}
|
||||
|
||||
/** Dispatch measurements to all regional probes in parallel, for every site. */
|
||||
export async function runMeasurements(env: Env, opts: RunOptions): Promise<InsertParams[]> {
|
||||
const { source, sha = null, prNumber = null, note = null, sites = SITES } = opts;
|
||||
|
||||
// Fan out across (site × region). We run all probes in parallel -- each one
|
||||
// issues N requests per route on its own, so the measurement load on the
|
||||
// demos is bounded regardless of how many sites we have.
|
||||
const probePromises = sites.flatMap((site) =>
|
||||
REGIONS.map(async (region) => {
|
||||
const binding = PROBE_BINDINGS[region];
|
||||
const probe = env[binding];
|
||||
const payload = {
|
||||
targetUrl: site.targetUrl,
|
||||
routes: TARGET_ROUTES.map((r) => ({ path: r.path, label: r.label })),
|
||||
warmRequests: WARM_REQUESTS,
|
||||
region,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await probe.fetch("https://probe/measure", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error(
|
||||
`Probe ${region} failed for site=${site.id}: ${response.status} ${errText}`,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = await response.json<MeasureResponse>();
|
||||
|
||||
return data.results.map(
|
||||
(r): InsertParams => ({
|
||||
id: generateId(),
|
||||
sha,
|
||||
prNumber,
|
||||
route: r.path,
|
||||
region,
|
||||
coldTtfbMs: r.coldTtfbMs,
|
||||
warmTtfbMs: r.warmTtfbMs,
|
||||
p95TtfbMs: r.p95TtfbMs,
|
||||
statusCode: r.statusCode,
|
||||
cfColo: r.cfColo,
|
||||
cfPlacement: r.cfPlacement,
|
||||
coldServerTimings: r.coldServerTimings,
|
||||
warmServerTimings: r.warmServerTimings,
|
||||
note,
|
||||
source,
|
||||
site: site.id,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(`Probe ${region} error for site=${site.id}:`, err);
|
||||
return [];
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const allResults = await Promise.all(probePromises);
|
||||
return allResults.flat();
|
||||
}
|
||||
111
infra/perf-monitor/src/routes.ts
Normal file
111
infra/perf-monitor/src/routes.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
/** Target routes to measure and their thresholds. */
|
||||
|
||||
export interface TargetRoute {
|
||||
path: string;
|
||||
label: string;
|
||||
/** Cold TTFB threshold in ms -- CI fails if exceeded. */
|
||||
coldThresholdMs: number;
|
||||
/**
|
||||
* HTTP status codes considered valid for this route. If a measurement returns
|
||||
* something outside this set, the CI trigger marks it as a sanity-check failure.
|
||||
* Measuring a 404 or 500 response tells us nothing about real-world perf -- the
|
||||
* route is either broken or has drifted (e.g. a referenced post was deleted).
|
||||
*
|
||||
* Note: the probe follows redirects, so this describes the final response status.
|
||||
* `/_emdash/admin` 302s to the login page (200), so 200 covers it.
|
||||
*/
|
||||
expectedStatuses: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A deployed demo we measure. Sites share the same route set and are compared
|
||||
* head-to-head on the dashboard. `blog` is the baseline; `cache` runs with
|
||||
* Astro's experimental cache provider enabled.
|
||||
*/
|
||||
export interface Site {
|
||||
/** Stable slug stored in `perf_results.site`. */
|
||||
id: string;
|
||||
label: string;
|
||||
targetUrl: string;
|
||||
/** Cloudflare Worker name — matched against build.succeeded events. */
|
||||
workerName: string;
|
||||
}
|
||||
|
||||
export const SITES: readonly Site[] = [
|
||||
{
|
||||
id: "blog",
|
||||
label: "Baseline",
|
||||
targetUrl: "https://blog-demo.emdashcms.com",
|
||||
workerName: "emdash-demo-blog",
|
||||
},
|
||||
{
|
||||
id: "cache",
|
||||
label: "Astro cache",
|
||||
targetUrl: "https://cache-demo.emdashcms.com",
|
||||
workerName: "emdash-demo-cache",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_SITE_ID = "blog";
|
||||
|
||||
export function getSite(id: string): Site | undefined {
|
||||
return SITES.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker name whose build.succeeded events drive deploy-attributed
|
||||
* measurements. Both sites build from the same repo on every main-branch
|
||||
* commit, so measuring on the baseline worker's event covers both (see
|
||||
* `handleBuildSucceeded`). If only cache-demo deploys (rare), the cron
|
||||
* job will catch it on the next tick.
|
||||
*/
|
||||
export const TRIGGER_WORKER_NAME = "emdash-demo-blog";
|
||||
|
||||
/**
|
||||
* GitHub repo used for PR number lookup. SHA -> merged PR resolution happens
|
||||
* via the GitHub API when a deploy event arrives.
|
||||
*/
|
||||
export const GITHUB_REPO = "emdash-cms/emdash";
|
||||
|
||||
/**
|
||||
* Routes we measure. Each exercises a different code path on the demo:
|
||||
* - "/" hits the homepage template and queries the latest posts
|
||||
* - "/posts/<slug>" renders a single post (different template + single-row fetch)
|
||||
* - "/_emdash/admin" returns a redirect from the admin root -- measures auth middleware latency
|
||||
*
|
||||
* We avoid `/_emdash/api/content/*` -- it requires auth and returns 401 immediately,
|
||||
* which doesn't reflect real query latency.
|
||||
*/
|
||||
export const TARGET_ROUTES: TargetRoute[] = [
|
||||
{
|
||||
path: "/",
|
||||
label: "Homepage",
|
||||
coldThresholdMs: 2000,
|
||||
expectedStatuses: [200],
|
||||
},
|
||||
{
|
||||
path: "/posts/marshland-birds-at-the-lake-havasu-national-wildlife-refuge",
|
||||
label: "Single Post",
|
||||
coldThresholdMs: 2000,
|
||||
expectedStatuses: [200],
|
||||
},
|
||||
{
|
||||
path: "/_emdash/admin",
|
||||
label: "Admin (login page)",
|
||||
coldThresholdMs: 1500,
|
||||
expectedStatuses: [200],
|
||||
},
|
||||
];
|
||||
|
||||
export const REGIONS = ["use", "euw", "ape", "aps"] as const;
|
||||
export type Region = (typeof REGIONS)[number];
|
||||
|
||||
export const REGION_LABELS: Record<Region, string> = {
|
||||
use: "US East",
|
||||
euw: "Europe West",
|
||||
ape: "Asia Pacific East",
|
||||
aps: "Asia Pacific South",
|
||||
};
|
||||
|
||||
/** Number of warm requests per route (we take the median). */
|
||||
export const WARM_REQUESTS = 5;
|
||||
245
infra/perf-monitor/src/store.ts
Normal file
245
infra/perf-monitor/src/store.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
/** D1 storage layer for perf results. */
|
||||
|
||||
/** All valid values for the `source` column. */
|
||||
export type Source = "deploy" | "cron" | "manual";
|
||||
|
||||
export interface PerfResult {
|
||||
id: string;
|
||||
sha: string | null;
|
||||
pr_number: number | null;
|
||||
route: string;
|
||||
region: string;
|
||||
cold_ttfb_ms: number | null;
|
||||
warm_ttfb_ms: number | null;
|
||||
p95_ttfb_ms: number | null;
|
||||
status_code: number | null;
|
||||
cf_colo: string | null;
|
||||
cf_placement: string | null;
|
||||
/** Raw JSON string as stored. Use {@link parseColdServerTimings} to decode. */
|
||||
cold_server_timings: string | null;
|
||||
/**
|
||||
* Median duration per metric across warm requests, same JSON shape as
|
||||
* `cold_server_timings`. Null when the target didn't emit Server-Timing
|
||||
* on warm responses, or when no warm requests were issued.
|
||||
*/
|
||||
warm_server_timings: string | null;
|
||||
note: string | null;
|
||||
timestamp: string;
|
||||
source: string;
|
||||
site: string;
|
||||
}
|
||||
|
||||
export interface InsertParams {
|
||||
id: string;
|
||||
sha: string | null;
|
||||
prNumber: number | null;
|
||||
route: string;
|
||||
region: string;
|
||||
coldTtfbMs: number | null;
|
||||
warmTtfbMs: number | null;
|
||||
p95TtfbMs: number | null;
|
||||
statusCode: number | null;
|
||||
cfColo: string | null;
|
||||
cfPlacement: string | null;
|
||||
/** Will be JSON.stringify'd on the way in. Null if unavailable. */
|
||||
coldServerTimings: Record<string, { dur: number; desc?: string }> | null;
|
||||
/** Median-per-metric snapshot of warm Server-Timing. Null if unavailable. */
|
||||
warmServerTimings: Record<string, { dur: number; desc?: string }> | null;
|
||||
note: string | null;
|
||||
source: Source;
|
||||
site: string;
|
||||
}
|
||||
|
||||
/** Column list shared between insertResult and insertResults. */
|
||||
const INSERT_COLUMNS =
|
||||
"id, sha, pr_number, route, region, cold_ttfb_ms, warm_ttfb_ms, p95_ttfb_ms, status_code, cf_colo, cf_placement, cold_server_timings, warm_server_timings, note, source, site";
|
||||
const INSERT_PLACEHOLDERS = "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?";
|
||||
|
||||
function bindInsert(stmt: D1PreparedStatement, p: InsertParams): D1PreparedStatement {
|
||||
return stmt.bind(
|
||||
p.id,
|
||||
p.sha,
|
||||
p.prNumber,
|
||||
p.route,
|
||||
p.region,
|
||||
p.coldTtfbMs,
|
||||
p.warmTtfbMs,
|
||||
p.p95TtfbMs,
|
||||
p.statusCode,
|
||||
p.cfColo,
|
||||
p.cfPlacement,
|
||||
p.coldServerTimings ? JSON.stringify(p.coldServerTimings) : null,
|
||||
p.warmServerTimings ? JSON.stringify(p.warmServerTimings) : null,
|
||||
p.note,
|
||||
p.source,
|
||||
p.site,
|
||||
);
|
||||
}
|
||||
|
||||
/** Insert a single measurement result. */
|
||||
export async function insertResult(db: D1Database, params: InsertParams): Promise<void> {
|
||||
await bindInsert(
|
||||
db.prepare(`INSERT INTO perf_results (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`),
|
||||
params,
|
||||
).run();
|
||||
}
|
||||
|
||||
/** Insert a batch of results in a single transaction. */
|
||||
export async function insertResults(db: D1Database, results: InsertParams[]): Promise<void> {
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO perf_results (${INSERT_COLUMNS}) VALUES (${INSERT_PLACEHOLDERS})`,
|
||||
);
|
||||
await db.batch(results.map((p) => bindInsert(stmt, p)));
|
||||
}
|
||||
|
||||
export interface QueryParams {
|
||||
route?: string;
|
||||
region?: string;
|
||||
source?: Source;
|
||||
site?: string;
|
||||
since?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an ISO-8601 timestamp (e.g. "2026-04-20T05:00:00.000Z") to the
|
||||
* " "-separated form D1's `datetime('now')` writes ("2026-04-20 05:00:00").
|
||||
*
|
||||
* SQLite compares TEXT lexicographically: space (0x20) sorts before "T"
|
||||
* (0x54). If we pass the client's ISO string straight into `timestamp >= ?`,
|
||||
* any stored row whose calendar date matches the since-boundary compares
|
||||
* LESS than since regardless of its actual time, so same-day filters (1h,
|
||||
* and the "today" portion of 24h) silently return zero rows.
|
||||
*/
|
||||
const SINCE_TIMESTAMP_RE = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})/;
|
||||
|
||||
function normalizeSince(since: string): string {
|
||||
const match = SINCE_TIMESTAMP_RE.exec(since);
|
||||
return match ? `${match[1]} ${match[2]}` : since;
|
||||
}
|
||||
|
||||
/** Query historical results with optional filters. */
|
||||
export async function queryResults(db: D1Database, params: QueryParams): Promise<PerfResult[]> {
|
||||
const conditions: string[] = [];
|
||||
const bindings: (string | number)[] = [];
|
||||
|
||||
if (params.route) {
|
||||
conditions.push("route = ?");
|
||||
bindings.push(params.route);
|
||||
}
|
||||
if (params.region) {
|
||||
conditions.push("region = ?");
|
||||
bindings.push(params.region);
|
||||
}
|
||||
if (params.source) {
|
||||
conditions.push("source = ?");
|
||||
bindings.push(params.source);
|
||||
}
|
||||
if (params.site) {
|
||||
conditions.push("site = ?");
|
||||
bindings.push(params.site);
|
||||
}
|
||||
if (params.since) {
|
||||
conditions.push("timestamp >= ?");
|
||||
bindings.push(normalizeSince(params.since));
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
||||
const limit = Math.min(params.limit ?? 500, 1000);
|
||||
|
||||
const query = `SELECT * FROM perf_results ${where} ORDER BY timestamp DESC LIMIT ?`;
|
||||
bindings.push(limit);
|
||||
|
||||
const result = await db
|
||||
.prepare(query)
|
||||
.bind(...bindings)
|
||||
.all<PerfResult>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest result per route/region combo for a given site.
|
||||
* Manual runs are excluded -- they're ad-hoc probes and would otherwise
|
||||
* poison the dashboard's "current state" cards whenever one was the most
|
||||
* recent sample.
|
||||
*/
|
||||
export async function getLatestResults(db: D1Database, site: string): Promise<PerfResult[]> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT p.* FROM perf_results p
|
||||
INNER JOIN (
|
||||
SELECT route, region, MAX(timestamp) as max_ts
|
||||
FROM perf_results
|
||||
WHERE source != 'manual' AND site = ?
|
||||
GROUP BY route, region
|
||||
) latest ON p.route = latest.route AND p.region = latest.region AND p.timestamp = latest.max_ts
|
||||
WHERE p.source != 'manual' AND p.site = ?
|
||||
ORDER BY p.region, p.route`,
|
||||
)
|
||||
.bind(site, site)
|
||||
.all<PerfResult>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get rolling medians for each route/region over the last N days for a given site.
|
||||
* Manual runs are excluded so ad-hoc probes don't pull the baseline around.
|
||||
*/
|
||||
export async function getRollingMedians(
|
||||
db: D1Database,
|
||||
site: string,
|
||||
days: number = 7,
|
||||
): Promise<
|
||||
Array<{ route: string; region: string; median_cold: number; median_warm: number; count: number }>
|
||||
> {
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
route,
|
||||
region,
|
||||
COUNT(*) as count,
|
||||
-- SQLite doesn't have PERCENTILE_CONT, so we approximate with AVG of middle values
|
||||
AVG(cold_ttfb_ms) as median_cold,
|
||||
AVG(warm_ttfb_ms) as median_warm
|
||||
FROM perf_results
|
||||
WHERE timestamp >= datetime('now', ?)
|
||||
AND cold_ttfb_ms IS NOT NULL
|
||||
AND source != 'manual'
|
||||
AND site = ?
|
||||
GROUP BY route, region
|
||||
ORDER BY region, route`,
|
||||
)
|
||||
.bind(`-${days} days`, site)
|
||||
.all<{
|
||||
route: string;
|
||||
region: string;
|
||||
median_cold: number;
|
||||
median_warm: number;
|
||||
count: number;
|
||||
}>();
|
||||
return result.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all deploy-triggered results (with SHA and PR info) for chart markers.
|
||||
* Only 'deploy' source has SHA attribution -- 'cron' is untagged baseline.
|
||||
*/
|
||||
export async function getDeployResults(
|
||||
db: D1Database,
|
||||
site: string,
|
||||
since?: string,
|
||||
): Promise<PerfResult[]> {
|
||||
const sinceClause = since ? "AND timestamp >= ?" : "";
|
||||
const bindings: string[] = [site];
|
||||
if (since) bindings.push(normalizeSince(since));
|
||||
|
||||
const result = await db
|
||||
.prepare(
|
||||
`SELECT * FROM perf_results
|
||||
WHERE source = 'deploy' AND site = ? ${sinceClause}
|
||||
ORDER BY timestamp ASC`,
|
||||
)
|
||||
.bind(...bindings)
|
||||
.all<PerfResult>();
|
||||
return result.results;
|
||||
}
|
||||
17
infra/perf-monitor/tsconfig.json
Normal file
17
infra/perf-monitor/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["es2023"],
|
||||
"types": [],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noImplicitOverride": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "probe/src", "worker-configuration.d.ts"]
|
||||
}
|
||||
29
infra/perf-monitor/vite.config.ts
Normal file
29
infra/perf-monitor/vite.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const PROBE_REGIONS = [
|
||||
{ id: "use", region: "aws:us-east-1" },
|
||||
{ id: "euw", region: "aws:eu-west-2" },
|
||||
{ id: "ape", region: "aws:ap-northeast-1" },
|
||||
{ id: "aps", region: "aws:ap-southeast-1" },
|
||||
] as const;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
cloudflare({
|
||||
configPath: "./wrangler.jsonc",
|
||||
auxiliaryWorkers: PROBE_REGIONS.map((probe) => ({
|
||||
config: (_, { entryWorkerConfig }) => ({
|
||||
name: `emdash-perf-probe-${probe.id}`,
|
||||
main: "./probe/src/index.ts",
|
||||
account_id: entryWorkerConfig.account_id,
|
||||
compatibility_date: entryWorkerConfig.compatibility_date,
|
||||
compatibility_flags: entryWorkerConfig.compatibility_flags,
|
||||
placement: {
|
||||
region: probe.region,
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}),
|
||||
],
|
||||
});
|
||||
14059
infra/perf-monitor/worker-configuration.d.ts
vendored
Normal file
14059
infra/perf-monitor/worker-configuration.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
61
infra/perf-monitor/wrangler.jsonc
Normal file
61
infra/perf-monitor/wrangler.jsonc
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "emdash-perf-coordinator",
|
||||
"main": "src/index.ts",
|
||||
"account_id": "1f74638c495bc9f0330ce5c8e64c1b6b",
|
||||
"compatibility_date": "2026-04-01",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "perf.emdashcms.com",
|
||||
"zone_name": "emdashcms.com",
|
||||
"custom_domain": true,
|
||||
},
|
||||
],
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "emdash_perf",
|
||||
"database_id": "84918738-8904-49bb-a306-b58b96edfc08",
|
||||
"migrations_dir": "migrations",
|
||||
},
|
||||
],
|
||||
"services": [
|
||||
{
|
||||
"binding": "PROBE_USE",
|
||||
"service": "emdash-perf-probe-use",
|
||||
},
|
||||
{
|
||||
"binding": "PROBE_EUW",
|
||||
"service": "emdash-perf-probe-euw",
|
||||
},
|
||||
{
|
||||
"binding": "PROBE_APE",
|
||||
"service": "emdash-perf-probe-ape",
|
||||
},
|
||||
{
|
||||
"binding": "PROBE_APS",
|
||||
"service": "emdash-perf-probe-aps",
|
||||
},
|
||||
],
|
||||
"triggers": {
|
||||
"crons": ["*/30 * * * *"],
|
||||
},
|
||||
"assets": {
|
||||
"directory": "public",
|
||||
},
|
||||
"queues": {
|
||||
"consumers": [
|
||||
{
|
||||
"queue": "emdash-perf-deploy-events",
|
||||
"max_batch_size": 10,
|
||||
"max_batch_timeout": 5,
|
||||
"max_retries": 3,
|
||||
"dead_letter_queue": "emdash-perf-deploy-events-dlq",
|
||||
},
|
||||
],
|
||||
},
|
||||
"observability": {
|
||||
"enabled": true,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user