first commit

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

View File

@@ -0,0 +1,18 @@
import cloudflare from "@astrojs/cloudflare";
import react from "@astrojs/react";
import { previewDatabase } from "@emdashcms/cloudflare";
import { defineConfig } from "astro/config";
import emdash from "emdash/astro";
export default defineConfig({
output: "server",
adapter: cloudflare(),
integrations: [
react(),
emdash({
// DO-backed preview database — populated from source site snapshots
database: previewDatabase({ binding: "PREVIEW_DB" }),
}),
],
devToolbar: { enabled: false },
});

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

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

View File

@@ -0,0 +1,31 @@
{
"name": "@emdashcms/demo-preview",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"build:all": "pnpm run --filter @emdashcms/demo-preview... build",
"preview": "astro preview",
"deploy": "pnpm build:all && wrangler deploy",
"typecheck": "astro check"
},
"dependencies": {
"@astrojs/cloudflare": "catalog:",
"@astrojs/react": "catalog:",
"@emdashcms/cloudflare": "workspace:*",
"@tanstack/react-query": "catalog:",
"@tanstack/react-router": "catalog:",
"astro": "catalog:",
"emdash": "workspace:*",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@astrojs/check": "catalog:",
"@cloudflare/workers-types": "catalog:",
"@types/node": "catalog:",
"wrangler": "catalog:"
}
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,448 @@
---
import { getEmDashCollection, getEntryTerms } 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";
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
// Find the first post with a featured image for the hero
const featuredPost = sortedPosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? sortedPosts.indexOf(featuredPost) : -1;
// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = sortedPosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
// Total posts shown = featured (if any) + grid posts
const totalShown = (featuredPost ? 1 : 0) + gridPosts.length;
const hasMorePosts = sortedPosts.length > totalShown;
// Fetch tags for featured post (bylines are already hydrated by getEmDashCollection)
let featuredTags: Array<{ slug: string; label: string }> = [];
const featuredBylines = featuredPost?.data.bylines ?? [];
if (featuredPost) {
const tags = await getEntryTerms("posts", featuredPost.data.id, "tag");
featuredTags = tags.map((t) => ({ slug: t.slug, label: t.label }));
}
// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
const gridPostsWithTags = await Promise.all(
gridPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return {
post,
tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
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="My Blog" description="Welcome to my blog">
{
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>

View File

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

View File

@@ -0,0 +1,958 @@
---
import {
getEmDashEntry,
getEmDashCollection,
getEntryTerms,
getSeoMeta,
} 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";
const { slug } = Astro.params;
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);
// Generate SEO meta from content
const seo = getSeoMeta(post, {
siteTitle: "My Blog",
siteUrl: Astro.url.origin,
path: `/posts/${slug}`,
defaultOgImage: featuredImageUrl,
});
// Get tags for this post
// Note: post.id is the slug, post.data.id is the database ULID
const tags = await getEntryTerms("posts", post.data.id, "tag");
// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];
// Get reading time
const readingTime = getReadingTime(post.data.content);
// Get other posts for "More posts" section, with their tags
// Fetch a few extra in case the current post is among them
const { entries: recentPosts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 4,
});
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
// Fetch tags for related posts (bylines are already hydrated by getEmDashCollection)
const otherPostsWithTags = await Promise.all(
otherPosts.map(async (p) => {
const postTags = await getEntryTerms("posts", p.data.id, "tag");
const postBylines = p.data.bylines ?? [];
return { post: p, tags: postTags, bylines: postBylines };
})
);
const publishDate =
post.data.publishedAt?.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
}) ?? null;
---
<Base
title={seo.title}
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.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>

View File

@@ -0,0 +1,268 @@
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";
const { entries: posts, cacheHint } = await getEmDashCollection("posts");
Astro.cache.set(cacheHint);
const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
const postsWithTags = await Promise.all(
sortedPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return { post, tags, 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>
{
sortedPosts.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>

View File

@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection } from "emdash";
const siteTitle = "My Blog";
const siteDescription = "A blog about software, design, and the occasional stray thought.";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site?.toString() || url.origin;
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(siteDescription)}</description>
<link>${siteUrl}</link>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml"/>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
${items}
</channel>
</rss>`;
return new Response(rss, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600",
},
});
};
const XML_ESCAPE_PATTERNS = [
[/&/g, "&amp;"],
[/</g, "&lt;"],
[/>/g, "&gt;"],
[/"/g, "&quot;"],
[/'/g, "&apos;"],
] as const;
function escapeXml(str: string): string {
let result = str;
for (const [pattern, replacement] of XML_ESCAPE_PATTERNS) {
result = result.replace(pattern, replacement);
}
return result;
}

View File

@@ -0,0 +1,141 @@
---
export const prerender = false;
import { getEmDashCollection } from "emdash";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime, extractText } from "../utils/reading-time";
const query = Astro.url.searchParams.get("q")?.trim() || "";
const { entries: allPosts } = await getEmDashCollection("posts");
// Simple search: match query against title, excerpt, and content
function matchesQuery(post: (typeof allPosts)[0], q: string): boolean {
if (!q) return false;
const lower = q.toLowerCase();
const title = (post.data.title || "").toLowerCase();
const excerpt = (post.data.excerpt || "").toLowerCase();
// Extract plain text from portable text blocks (avoids matching on _type, _key, etc.)
const content = extractText(post.data.content).toLowerCase();
return (
title.includes(lower) || excerpt.includes(lower) || content.includes(lower)
);
}
const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : [];
---
<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 && (
<div class="search-results">
{results.map((post) => (
<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)}
/>
))}
</div>
)
}
{!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 {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
</style>

View File

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

View File

@@ -0,0 +1,110 @@
/*
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.
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);
*/
/* --- Fonts ---
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
*/
/* --- Type scale ---
--font-size-xs: 0.8125rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 2rem;
--font-size-4xl: 2.5rem;
--font-size-5xl: 3.5rem;
*/
/* --- Line heights ---
--leading-tight: 1.15;
--leading-snug: 1.3;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
*/
/* --- Letter spacing ---
--tracking-tight: -0.03em; used on h1 and large titles
--tracking-snug: -0.02em; used on h2h6, site/card titles
--tracking-wide: 0.06em; used on meta labels, TOC/widget titles
--tracking-wider: 0.08em; used on footer headings, section labels
*/
/* --- Spacing ---
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 0.75rem;
--spacing-4: 1rem;
--spacing-5: 1.25rem;
--spacing-6: 1.5rem;
--spacing-8: 2rem;
--spacing-10: 2.5rem;
--spacing-12: 3rem;
--spacing-16: 4rem;
--spacing-20: 5rem;
--spacing-24: 6rem;
*/
/* --- Layout ---
--content-width: 680px; article/page body column width
--wide-width: 1200px; max container width (home, archives)
--gutter-width: 200px; right sidebar column (TOC) on article pages
--meta-col-width: 180px; left meta column on article pages
--nav-height: 64px; sticky header height
--search-input-width: 180px; nav search box width
*/
/* --- Borders & radius ---
--radius: 4px;
--radius-lg: 8px;
*/
/* --- Transitions ---
--transition-fast: 120ms ease;
--transition-base: 180ms ease;
*/
/* --- Avatars ---
--avatar-size-xs: 18px; card byline avatars
--avatar-size-sm: 20px; post list byline avatars
--avatar-size-md: 24px; featured post byline avatars
--avatar-size-lg: 32px; single post byline avatars
*/
/* --- Shadows ---
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.12);
--shadow-btn-active: 0 1px 2px rgba(0, 0, 0, 0.05);
*/
/* --- Misc ---
--tag-padding-y: 2px; vertical padding on tag pills
*/
}

View File

@@ -0,0 +1,44 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const WHITESPACE_REGEX = /\s+/;
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(
(
block,
): block is PortableTextBlock & {
children: Array<{ _type: string; text?: string }>;
} => block._type === "block" && Array.isArray(block.children),
)
.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 wordCount = text.split(WHITESPACE_REGEX).filter(Boolean).length;
const minutes = Math.ceil(wordCount / WORDS_PER_MINUTE);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}

View File

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

View File

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

View File

@@ -0,0 +1,35 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "emdash-preview",
// Custom entrypoint that exports EmDashPreviewDB
"main": "./src/worker.ts",
"compatibility_date": "2026-01-14",
"compatibility_flags": [
"nodejs_compat",
"disable_nodejs_process_v2"
],
"assets": {
"directory": "./dist",
},
// Durable Object for preview databases
"durable_objects": {
"bindings": [
{
"name": "PREVIEW_DB",
"class_name": "EmDashPreviewDB",
},
],
},
// DO migration — declares the SQLite-backed class
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": [
"EmDashPreviewDB"
],
},
],
"observability": {
"enabled": true,
},
}