capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)

pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
This commit is contained in:
dckc committed 2026-10-07 19:57:43 -05:00
1 parent af8dd75fd1
commit 4b4ef8fea6
52 files changed
+18118

No files matched your search

+281
View File
@@ -0,0 +1,281 @@
---
import type { ContentBylineCredit, EditProxy, MediaValue } 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[];
edit?: EditProxy;
}
const {
title,
excerpt,
featuredImage,
href,
date,
readingTime,
tags,
bylines,
edit,
} = 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" {...edit?.featured_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" {...edit?.title}>{title}</h2>
{excerpt && <p class="card-excerpt" {...edit?.excerpt}>{excerpt}</p>}
</div>
</a>
{
tags && tags.length > 0 && (
<div class="card-tags">
{tags.slice(0, 2).map((tag) => (
<a href={`/tag/${tag.slug}`} class="card-tag">
{tag.label}
</a>
))}
</div>
)
}
</article>
<style>
.post-card {
display: flex;
flex-direction: column;
}
.card-link {
display: block;
text-decoration: none;
color: inherit;
}
.card-image {
aspect-ratio: 16 / 10;
overflow: hidden;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-image img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
.card-link:hover .card-image img {
transform: scale(1.03);
}
.card-placeholder {
aspect-ratio: 16 / 10;
border-radius: var(--radius-lg);
background: var(--color-surface);
margin-bottom: var(--spacing-4);
}
.card-body {
flex: 1;
}
.card-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: 0;
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-2);
}
.card-meta time,
.card-meta span:not(.meta-dot) {
white-space: nowrap;
}
.meta-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-muted);
}
.card-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-heading);
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-brand);
}
.card-excerpt {
font-size: var(--font-size-base);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-3);
}
.card-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.card-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.card-bylines {
display: flex;
align-items: center;
gap: 2px;
white-space: nowrap;
}
.card-byline {
display: inline-flex;
align-items: center;
gap: var(--spacing-1);
}
.card-byline-avatar {
width: var(--avatar-size-xs);
height: var(--avatar-size-xs);
border-radius: 50%;
object-fit: cover;
}
.card-byline-name {
font-weight: 500;
color: var(--color-text-secondary);
}
.byline-more {
position: relative;
font-size: var(--font-size-xs);
color: var(--color-muted);
margin-left: 2px;
cursor: default;
border-radius: var(--radius);
outline-offset: 2px;
}
.byline-more:focus-visible {
outline: 2px solid var(--color-brand);
}
.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>
+908
View File
@@ -0,0 +1,908 @@
---
import { getMenuWithCacheHint, getSiteSettingsWithCacheHint } from "emdash";
import {
WidgetArea,
EmDashHead,
EmDashBodyStart,
EmDashBodyEnd,
} from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import LiveSearch from "emdash/ui/search";
import { Font } from "astro:assets";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
import "../styles/tokens.css";
import "../styles/theme.css";
interface Props {
title: string;
pageTitle?: string | null;
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,
pageTitle,
description,
image,
canonical,
robots,
type = "website",
publishedTime,
modifiedTime,
author,
content,
} = Astro.props;
const [settingsResult, menuResult] = await Promise.all([
getSiteSettingsWithCacheHint(),
getMenuWithCacheHint("primary"),
]);
if (Astro.cache?.enabled) {
Astro.cache.set(settingsResult.cacheHint);
Astro.cache.set(menuResult.cacheHint);
}
const { siteTitle, siteTagline, siteLogo } = resolveBlogSiteIdentity(
settingsResult.data,
);
const titleIncludesSiteName =
title === siteTitle ||
title.endsWith(` | ${siteTitle}`) ||
title.endsWith(` — ${siteTitle}`);
const fullTitle = titleIncludesSiteName ? title : `${title} — ${siteTitle}`;
const menu = menuResult.data;
// 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,
pageTitle: pageTitle ?? title,
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" />
<Font cssVariable="--font-body" preload />
<Font cssVariable="--font-mono" />
<title>{fullTitle}</title>
<EmDashHead page={pageCtx} />
<script is:inline>
// Apply an explicit theme choice immediately to prevent flash.
// With no cookie, color-scheme: light dark follows the OS.
(function () {
var row = document.cookie.split("; ").find(function (item) {
return item.startsWith("theme=");
});
var theme = row ? row.slice(6) : null;
if (theme === "dark" || theme === "light") {
document.documentElement.classList.add(theme);
}
})();
</script>
</head>
<body>
<EmDashBodyStart page={pageCtx} />
<header class="site-header">
<nav class="nav">
<a href="/" class="site-title">
{
siteLogo ? (
<img
src={siteLogo.url}
alt={siteLogo.alt || siteTitle}
class="site-logo-img"
/>
) : (
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}
rel={
item.target === "_blank" ? "noopener noreferrer" : undefined
}
>
{item.label}
</a>
))
}
</div>
</div>
{
isLoggedIn && (
<a href="/_emdash/admin" class="nav-admin">
Admin
</a>
)
}
</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">
{
siteLogo ? (
<img
src={siteLogo.url}
alt={siteLogo.alt || siteTitle}
class="footer-logo-img"
/>
) : (
siteTitle
)
}
</a>
<p class="footer-tagline">{siteTagline}</p>
</div>
<div class="footer-nav">
<h2 class="footer-heading">Navigate</h2>
<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>
))
}
</ul>
</div>
<div class="footer-nav">
<h2 class="footer-heading">Connect</h2>
<ul class="footer-links">
<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 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");
} else {
setCookie("theme", theme);
root.classList.remove("light", "dark");
root.classList.add(theme);
}
updateActiveBtn(theme);
}
function updateActiveBtn(theme: string) {
themeBtns.forEach((btn) => {
const active = btn.dataset.theme === theme;
btn.classList.toggle("active", active);
btn.setAttribute("aria-pressed", String(active));
});
}
function getStoredTheme(): string {
const row = document.cookie
.split("; ")
.find((item) => item.startsWith("theme="));
return row?.slice(6) || "system";
}
// Initialize
const storedTheme = getStoredTheme();
setTheme(storedTheme);
themeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
setTheme(btn.dataset.theme || "system");
});
});
</script>
<style is:global>
/*
* Establish layer order: "base" has lower priority than unlayered
* styles, so theme.css overrides always win regardless of source
* order in the bundled CSS.
*/
@layer base;
@layer base {
*: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;
}
/*
* Design tokens live in src/styles/tokens.css. Colors are
* defined with light-dark(); these rules pin the scheme when
* the footer switcher sets an explicit choice. With no class,
* color-scheme: light dark follows the OS preference. The
* @supports guard keeps the toggle from forcing dark UA form
* controls on browsers stuck on the plain-light fallback.
*/
@supports (color: light-dark(#000, #fff)) {
:root.light {
color-scheme: light;
}
:root.dark {
color-scheme: dark;
}
}
html {
scroll-behavior: smooth;
}
body {
font-family: var(--font-body);
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-brand);
text-decoration: none;
transition: color var(--transition-fast);
}
a:where(:not([class*="emdash"]):not([class*="ec-"])):hover {
color: var(--color-brand-hover);
}
img {
max-width: 100%;
height: auto;
display: block;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: var(--font-heading);
line-height: var(--leading-tight);
font-weight: var(--font-weight-heading);
letter-spacing: var(--tracking-snug);
}
h1 {
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
}
::selection {
background: var(--color-brand);
color: var(--color-on-brand);
}
}
</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-brand);
}
.site-logo-img {
height: 48px;
width: auto;
display: block;
margin: -8px 0;
}
.nav-right {
display: flex;
align-items: center;
gap: var(--spacing-6);
margin-inline-start: auto;
}
.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-brand);
}
.nav-admin {
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.nav-admin:hover {
color: var(--color-text);
}
/* Search styling */
.site-search {
position: relative;
width: var(--search-input-width);
--emdash-search-border-focus: var(--color-brand);
}
:global(.site-search-input) {
width: var(--search-input-width);
padding: var(--spacing-2) var(--spacing-3);
font-family: var(--font-body);
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-brand) !important;
box-shadow: 0 0 0 3px var(--color-brand-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-lg);
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-logo-img {
height: 24px;
width: auto;
}
.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-sm);
}
.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 {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
height: auto;
column-gap: var(--spacing-3);
row-gap: var(--spacing-2);
padding: var(--spacing-3) var(--spacing-4);
}
.site-title {
grid-column: 1;
grid-row: 1;
font-size: var(--font-size-base);
min-width: 0;
}
.nav-right {
grid-column: 1 / -1;
grid-row: 2;
display: grid;
row-gap: var(--spacing-2);
}
.site-search {
grid-column: 1;
width: 100%;
max-width: none;
}
:global(.site-search-input) {
width: 100% !important;
padding: var(--spacing-1) var(--spacing-2) !important;
font-size: var(--font-size-sm) !important;
}
.nav-links {
grid-column: 1;
display: flex;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
flex-wrap: wrap;
justify-content: flex-start;
}
.nav-admin {
grid-column: 2;
grid-row: 1;
justify-self: end;
font-size: var(--font-size-xs);
margin-inline-start: 0;
white-space: nowrap;
}
.footer-grid {
grid-template-columns: 1fr;
}
.footer-brand {
grid-column: span 1;
}
.footer-bottom {
flex-direction: column;
gap: var(--spacing-4);
text-align: center;
}
.footer-controls {
flex-wrap: wrap;
justify-content: center;
}
}
</style>
<script>
// ⌘K / Ctrl+K to focus search
document.addEventListener("keydown", (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
const searchInput = document.querySelector(
".site-search-input",
) as HTMLInputElement;
if (searchInput) {
searchInput.focus();
}
}
});
</script>
<EmDashBodyEnd page={pageCtx} />
</body>
</html>
+13
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() }),
};
+33
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>
+146
View File
@@ -0,0 +1,146 @@
---
import {
getTaxonomyTermsWithCacheHint,
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 termsResult = await getTaxonomyTermsWithCacheHint("category", {
includeCounts: false,
});
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
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>
</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 }))}
edit={post.edit}
/>
))}
</div>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href={`/category/${term.slug}`}>First page</a>}
{
nextCursor && (
<a
href={`/category/${term.slug}?cursor=${encodeURIComponent(nextCursor)}`}
>
Next page
</a>
)
}
</nav>
</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-brand);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.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>
+466
View File
@@ -0,0 +1,466 @@
---
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);
if (Astro.cache?.enabled) 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">
<h1>No posts yet</h1>
<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" {...featuredPost.edit.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.edit.title}>{featuredPost.data.title}</h1>
</a>
{featuredPost.data.excerpt && (
<p class="featured-excerpt" {...featuredPost.edit.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}
edit={post.edit}
/>
))}
</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: var(--font-weight-display);
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-brand);
}
.featured-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.featured-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-2);
}
.featured-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.featured-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Section header */
.section-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: var(--spacing-8);
padding-bottom: var(--spacing-4);
border-bottom: 1px solid var(--color-border-subtle);
}
.section-title {
font-size: var(--font-size-sm);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
color: var(--color-muted);
}
.section-link {
font-size: var(--font-size-sm);
color: var(--color-brand);
text-decoration: none;
transition: color var(--transition-fast);
}
.section-link:hover {
color: var(--color-brand-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: var(--font-weight-heading);
}
.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-brand);
color: var(--color-on-brand);
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-brand-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>
+124
View File
@@ -0,0 +1,124 @@
---
import {
getEmDashEntry,
getSeoMeta,
decodeSlug,
getSiteSettings,
} from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
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");
}
if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
const { siteTitle } = resolveBlogSiteIdentity(await getSiteSettings());
const seo = getSeoMeta(page, {
siteTitle,
siteUrl: Astro.url.origin,
path: Astro.url.pathname.replace(/\/$/, ""),
});
---
<Base
title={seo.title}
pageTitle={seo.ogTitle}
description={seo.description}
canonical={seo.canonical}
robots={seo.robots}
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" {...page.edit.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: var(--font-weight-display);
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>
+968
View File
@@ -0,0 +1,968 @@
---
import {
getEmDashEntry,
getEmDashCollection,
getTermsForEntries,
getSeoMeta,
decodeSlug,
getSiteSettings,
} from "emdash";
import { Image, PortableText, WidgetArea } from "emdash/ui";
import { Comments, CommentForm } from "emdash/ui/comments";
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");
}
if (Astro.cache?.enabled) 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: Astro.url.pathname.replace(/\/$/, ""),
defaultOgImage: featuredImageUrl,
});
// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];
// Get reading time
const readingTime = getReadingTime(post.data.content);
// This post's tags are already hydrated onto the entry by getEmDashEntry —
// no extra query needed.
const tags = post.data.terms?.tag ?? [];
// Fetch a few extra recent posts in case the current post is among them.
const { entries: recentPosts, cacheHint: recentPostsCacheHint } = await getEmDashCollection(
"posts",
{
orderBy: { published_at: "desc" },
limit: 4,
},
);
if (Astro.cache?.enabled) Astro.cache.set(recentPostsCacheHint);
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">
<h2 class="toc-title">On this page</h2>
<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}
edit={p.edit}
/>
),
)}
</div>
</div>
</section>
)
}
<script>
// Build table of contents from h2/h3 headings
function buildToc() {
const content = document.querySelector(".article-content");
const tocContainer = document.getElementById("toc-content");
if (!content || !tocContainer) return;
const headings = content.querySelectorAll("h2, h3");
if (headings.length === 0) {
// Hide TOC if no headings
const toc = document.querySelector(".toc") as HTMLElement | null;
if (toc) toc.style.display = "none";
return;
}
const list = document.createElement("ul");
list.className = "toc-list";
headings.forEach((heading, index) => {
// Add ID if missing
if (!heading.id) {
heading.id = `heading-${index}`;
}
const li = document.createElement("li");
li.className =
heading.tagName === "H3" ? "toc-item toc-item--nested" : "toc-item";
const link = document.createElement("a");
link.href = `#${heading.id}`;
link.className = "toc-link";
link.textContent = heading.textContent;
li.appendChild(link);
list.appendChild(li);
});
tocContainer.appendChild(list);
// Highlight current section on scroll
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const id = entry.target.id;
const link = tocContainer.querySelector(`a[href="#${id}"]`);
if (link) {
if (entry.isIntersecting) {
tocContainer
.querySelectorAll(".toc-link")
.forEach((l) => l.classList.remove("active"));
link.classList.add("active");
}
}
});
},
{ rootMargin: "-80px 0px -80% 0px" },
);
headings.forEach((heading) => observer.observe(heading));
}
buildToc();
</script>
</Base>
<style>
/* Article container */
.article {
max-width: var(--wide-width);
margin: 0 auto;
}
/* Hero image - full width within container */
.article-hero {
margin: var(--spacing-16) var(--spacing-6);
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-surface);
}
.article-hero img {
width: 100%;
height: auto;
max-height: 500px;
object-fit: cover;
}
/* Three-column grid */
.article-grid {
display: grid;
grid-template-columns:
var(--meta-col-width) minmax(0, var(--content-width))
var(--gutter-width);
gap: var(--spacing-10);
justify-content: center;
padding: 0 var(--spacing-6);
margin: var(--spacing-16) 0;
}
/* Left column: Meta */
.article-meta-col {
display: block;
}
.meta-sticky {
position: sticky;
top: calc(var(--nav-height) + var(--spacing-8));
}
.meta-block {
margin-bottom: var(--spacing-6);
}
.meta-label {
display: block;
font-size: var(--font-size-xs);
font-weight: 500;
text-transform: uppercase;
letter-spacing: var(--tracking-wide);
color: var(--color-muted);
margin-bottom: var(--spacing-1);
}
.meta-value {
display: block;
font-size: var(--font-size-sm);
color: var(--color-text-secondary);
}
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-1);
}
.meta-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.meta-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
/* Byline styles */
.bylines {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.byline {
display: flex;
align-items: center;
gap: var(--spacing-2);
}
.byline-avatar {
width: var(--avatar-size-lg);
height: var(--avatar-size-lg);
border-radius: 50%;
object-fit: cover;
}
.byline-info {
display: flex;
flex-direction: column;
}
.byline-name {
font-size: var(--font-size-sm);
font-weight: 500;
color: var(--color-text);
}
.byline-role {
font-size: var(--font-size-xs);
color: var(--color-muted);
}
/* Main content column */
.article-main {
min-width: 0;
}
.article-header {
margin-bottom: var(--spacing-10);
}
.article-header .article-meta {
display: none;
align-items: center;
flex-wrap: wrap;
column-gap: var(--spacing-3);
row-gap: var(--spacing-1);
font-size: var(--font-size-sm);
color: var(--color-muted);
margin-bottom: var(--spacing-4);
}
.article-meta-byline {
font-weight: 500;
color: var(--color-text-secondary);
}
.article-title {
font-size: clamp(2rem, 5vw, var(--font-size-5xl));
font-weight: var(--font-weight-display);
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-brand);
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-body);
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-brand);
box-shadow: 0 0 0 3px var(--color-brand-ring);
}
.sidebar-widgets :global(.widget-search__button) {
display: none;
}
/* Sidebar categories widget */
.sidebar-widgets :global(.widget-categories) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-categories li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-categories li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-categories__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-categories__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-categories__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Sidebar tags widget - pill style */
.sidebar-widgets :global(.widget-tags__cloud) {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
}
.sidebar-widgets :global(.widget-tags__cloud li) {
margin: 0;
}
.sidebar-widgets :global(.widget-tags__link) {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.sidebar-widgets :global(.widget-tags__link:hover) {
color: var(--color-text);
background: var(--color-border);
}
.sidebar-widgets :global(.widget-tags__count) {
display: none;
}
/* Sidebar recent posts widget */
.sidebar-widgets :global(.widget-recent-posts) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-recent-posts li) {
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-recent-posts li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-recent-posts a) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
line-height: var(--leading-snug);
}
.sidebar-widgets :global(.widget-recent-posts a:hover) {
color: var(--color-text);
}
/* Sidebar archives widget */
.sidebar-widgets :global(.widget-archives) {
list-style: none;
padding: 0;
margin: 0;
}
.sidebar-widgets :global(.widget-archives li) {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-2) 0;
border-bottom: 1px solid var(--color-border-subtle);
}
.sidebar-widgets :global(.widget-archives li:last-child) {
border-bottom: none;
}
.sidebar-widgets :global(.widget-archives__link) {
color: var(--color-text-secondary);
text-decoration: none;
transition: color var(--transition-fast);
}
.sidebar-widgets :global(.widget-archives__link:hover) {
color: var(--color-text);
}
.sidebar-widgets :global(.widget-archives__count) {
font-size: var(--font-size-xs);
color: var(--color-muted);
background: var(--color-surface);
padding: var(--tag-padding-y) var(--spacing-2);
border-radius: var(--radius);
}
/* Comments section */
.article-comments {
margin-top: var(--spacing-16);
padding-top: var(--spacing-10);
border-top: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments) {
--ec-comment-border: 1px solid var(--color-border);
}
.article-comments :global(.ec-comments-heading) {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-heading);
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-brand) !important;
color: var(--color-on-brand) !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: var(--font-weight-heading);
margin-bottom: var(--spacing-10);
}
.more-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8);
}
/* Responsive */
@media (max-width: 1100px) {
.article-grid {
grid-template-columns: minmax(0, var(--content-width));
gap: 0;
}
.article-meta-col,
.article-sidebar {
display: none;
}
.article-header .article-meta {
display: flex;
}
}
@media (max-width: 900px) {
.article-hero {
margin: var(--spacing-4) var(--spacing-4) var(--spacing-8);
border-radius: var(--radius);
}
.article-grid {
padding: 0 var(--spacing-4);
}
.more-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.article-title {
font-size: var(--font-size-3xl);
}
.more-grid {
grid-template-columns: 1fr;
}
}
</style>
+289
View File
@@ -0,0 +1,289 @@
---
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 cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) 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.edit.title}>{post.data.title}</h2>
{post.data.excerpt && (
<p class="post-excerpt" {...post.edit.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>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href="/posts">First page</a>}
{
nextCursor && (
<a href={`/posts?cursor=${encodeURIComponent(nextCursor)}`}>
Next page
</a>
)
}
</nav>
</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: var(--font-weight-display);
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: var(--font-weight-heading);
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-brand);
}
.post-excerpt {
font-size: var(--font-size-lg);
line-height: var(--leading-relaxed);
color: var(--color-text-secondary);
}
.post-tags {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-2);
margin-top: var(--spacing-4);
}
.post-tag {
display: inline-block;
padding: var(--tag-padding-y) var(--spacing-2);
font-size: var(--font-size-xs);
color: var(--color-text-secondary);
background: var(--color-surface);
border-radius: var(--radius);
text-decoration: none;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.post-tag:hover {
color: var(--color-text);
background: var(--color-border);
}
@media (max-width: 600px) {
.posts-page {
padding: var(--spacing-6) var(--spacing-4) var(--spacing-12);
}
.page-title {
font-size: var(--font-size-3xl);
}
.post-title {
font-size: var(--font-size-xl);
}
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import type { APIRoute } from "astro";
import { getEmDashCollection, getSiteSettings } from "emdash";
import { resolveBlogSiteIdentity } from "../utils/site-identity";
export const GET: APIRoute = async ({ site, url }) => {
const siteUrl = site ?? new URL(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 = new URL(`/posts/${post.id}`, siteUrl).href;
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.href}</link>
<atom:link href="${new URL("/rss.xml", siteUrl).href}" 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;
}
+196
View File
@@ -0,0 +1,196 @@
---
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">
<label for="site-search-query" class="visually-hidden"
>Search blog posts</label
>
<input
id="site-search-query"
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);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.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-brand);
}
.search-button {
padding: var(--spacing-2) var(--spacing-6);
font-size: var(--font-size-base);
background: var(--color-brand);
color: var(--color-on-brand);
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: var(--font-weight-heading);
line-height: var(--leading-snug);
margin-bottom: var(--spacing-2);
transition: color var(--transition-fast);
}
.result-link:hover .result-title {
color: var(--color-brand);
}
.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-brand-ring);
color: inherit;
padding: 0 0.1em;
border-radius: 2px;
}
</style>
+148
View File
@@ -0,0 +1,148 @@
---
import {
getTaxonomyTermsWithCacheHint,
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 termsResult = await getTaxonomyTermsWithCacheHint("tag", {
includeCounts: false,
});
const term = slug ? termsResult.data.find((item) => item.slug === slug) : null;
if (!term) {
return Astro.redirect("/404");
}
const cursor = Astro.url.searchParams.get("cursor") || undefined;
const {
entries: posts,
cacheHint,
nextCursor,
} = await getEmDashCollection("posts", {
where: { tag: term.slug },
orderBy: { published_at: "desc" },
limit: 20,
cursor,
});
if (Astro.cache?.enabled) {
Astro.cache.set(termsResult.cacheHint);
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>
</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 }))}
edit={post.edit}
/>
))}
</div>
)
}
<nav class="pagination" aria-label="Pagination">
{cursor && <a href={`/tag/${term.slug}`}>First page</a>}
{
nextCursor && (
<a
href={`/tag/${term.slug}?cursor=${encodeURIComponent(nextCursor)}`}
>
Next page
</a>
)
}
</nav>
</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-brand);
text-transform: uppercase;
letter-spacing: var(--tracking-wider);
margin-bottom: var(--spacing-2);
}
.archive-title {
font-size: var(--font-size-4xl);
font-weight: var(--font-weight-display);
letter-spacing: var(--tracking-tight);
margin-bottom: var(--spacing-2);
}
.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>
+33
View File
@@ -0,0 +1,33 @@
/*
theme.css -- your theme overrides. This is the file to edit to
retheme the site.
Every design token lives in src/styles/tokens.css (colors, type
scale, spacing, layout, shadows) with its default value. Override any
of them here: declarations in this file are unlayered, so they always
beat the @layer base defaults in tokens.css.
Colors are defined with light-dark(). Overriding with a plain color
changes light and dark mode at once; use light-dark(<light>, <dark>)
to set different values per mode.
Webfonts are loaded in astro.config.mjs (--font-body, --font-mono).
Swap the loaded font there; use overrides here for system fonts or a
separate heading face.
Example:
:root {
--color-brand: light-dark(#0f766e, #2dd4bf);
--font-heading: "Iowan Old Style", Georgia, serif;
--radius: 8px;
}
*/
:root {
}
/* Keep the signed-in Admin link separated from the primary navigation. */
.nav-admin {
margin-inline-start: var(--spacing-5);
}
+146
View File
@@ -0,0 +1,146 @@
/*
tokens.css -- the design tokens for this template. These are the
defaults; to customize the site, override any variable in
src/styles/theme.css rather than editing this file. Declarations in
theme.css are unlayered, so they always beat these @layer base
defaults -- no specificity tricks needed.
Colors use light-dark(): the first value applies in light mode, the
second in dark mode. Overriding a color with a plain value changes
both modes at once; use light-dark(<light>, <dark>) in an override to
keep them distinct.
--font-body is defined by the font pipeline in astro.config.mjs, not
here. To swap the loaded webfont, edit the fonts entry there. To use a
system font, or a different face for headings only, override
--font-body / --font-heading in theme.css.
*/
@layer base {
:root {
color-scheme: light dark;
/* Colors */
--color-bg: light-dark(#ffffff, #0d0d0d);
--color-bg-subtle: light-dark(#fafafa, #141414);
--color-text: light-dark(#1a1a1a, #ededed);
--color-text-secondary: light-dark(#525252, #a0a0a0);
--color-muted: light-dark(#8b8b8b, #6b6b6b);
--color-border: light-dark(#e5e5e5, #2a2a2a);
--color-border-subtle: light-dark(#f0f0f0, #1f1f1f);
--color-surface: light-dark(#f7f7f7, #181818);
--color-brand: light-dark(#0066cc, #4d9fff);
--color-brand-hover: light-dark(#0052a3, #6eb0ff);
--color-on-brand: white;
--color-brand-ring: color-mix(in srgb, var(--color-brand) 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-heading: var(--font-body);
--font-weight-heading: 600; /* h2-h6, card and list titles */
--font-weight-display: 700; /* h1 and large page titles */
/* 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; /* h1 and large titles */
--tracking-snug: -0.02em; /* h2-h6, site/card titles */
--tracking-wide: 0.06em; /* meta labels, TOC/widget titles */
--tracking-wider: 0.08em; /* 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;
/* 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 */
--content-width: 680px; /* article/page body column */
--wide-width: 1200px; /* max container (home, archives) */
--max-width: var(--content-width);
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 8px 30px rgba(0, 0, 0, 0.12);
/* Misc */
--tag-padding-y: 2px; /* vertical padding on tag pills */
}
/*
* Light-mode values for browsers without light-dark() (Safari < 17.5,
* Chrome < 123). Must mirror the light values above.
*/
@supports not (color: light-dark(#000, #fff)) {
:root {
color-scheme: light;
--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-brand: #0066cc;
--color-brand-hover: #0052a3;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
import type { PortableTextBlock } from "emdash";
const WORDS_PER_MINUTE = 200;
const CJK_CHARACTERS_PER_MINUTE = 500;
const WHITESPACE_REGEX = /\s+/;
const CJK_CHARACTER_REGEX =
/\p{Script=Han}|\p{Script=Hangul}|\p{Script=Hiragana}|\p{Script=Katakana}/gu;
type PortableTextSpan = {
_type: string;
text?: string;
};
type PortableTextTextBlock = PortableTextBlock & {
_type: "block";
children: PortableTextSpan[];
};
function isTextBlock(block: PortableTextBlock): block is PortableTextTextBlock {
return block._type === "block" && Array.isArray(block.children);
}
function countWords(text: string): number {
return text.split(WHITESPACE_REGEX).filter(Boolean).length;
}
function countCjkCharacters(text: string): number {
return text.match(CJK_CHARACTER_REGEX)?.length ?? 0;
}
/**
* Extract plain text from Portable Text blocks
*/
export function extractText(blocks: PortableTextBlock[] | undefined): string {
if (!blocks || !Array.isArray(blocks)) return "";
return blocks
.filter(isTextBlock)
.map((block) =>
block.children
.filter((child) => child._type === "span" && typeof child.text === "string")
.map((span) => span.text)
.join(""),
)
.join(" ");
}
/**
* Calculate reading time in minutes from Portable Text content
*/
export function getReadingTime(content: PortableTextBlock[] | undefined): number {
const text = extractText(content);
const cjkCharacterCount = countCjkCharacters(text);
const wordCount = countWords(text.replace(CJK_CHARACTER_REGEX, " "));
const minutes = Math.ceil(
wordCount / WORDS_PER_MINUTE + cjkCharacterCount / CJK_CHARACTERS_PER_MINUTE,
);
return Math.max(1, minutes);
}
/**
* Format reading time for display
*/
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}
+24
View File
@@ -0,0 +1,24 @@
/** Resolved media reference from getSiteSettings() */
export interface MediaReference {
mediaId: string;
alt?: string;
url?: string;
}
export interface BlogSiteIdentitySettings {
title?: string;
tagline?: string;
logo?: MediaReference;
favicon?: MediaReference;
}
const DEFAULT_SITE_TITLE = "My Blog";
const DEFAULT_SITE_TAGLINE = "Thoughts, stories, and ideas.";
export function resolveBlogSiteIdentity(settings?: BlogSiteIdentitySettings) {
return {
siteTitle: settings?.title ?? DEFAULT_SITE_TITLE,
siteTagline: settings?.tagline ?? DEFAULT_SITE_TAGLINE,
siteLogo: settings?.logo?.url ? settings.logo : null,
};
}
+8
View File
@@ -0,0 +1,8 @@
import handler, { createScheduledHandler, PluginBridge } from "@emdash-cms/cloudflare/worker";
export { PluginBridge };
export default {
...handler,
scheduled: createScheduledHandler(),
} satisfies ExportedHandler;