Initial commit: EmDash blog template

Fixed index.astro: escaped curly braces in code display block
to prevent Astro parser misinterpreting them as expressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kunthawat Greethong
2026-04-29 12:41:59 +07:00
commit 520c5f19e2
26 changed files with 5073 additions and 0 deletions

33
src/pages/404.astro Normal file
View File

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

View File

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

544
src/pages/index.astro Normal file
View File

@@ -0,0 +1,544 @@
---
import { getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
---
<Base title="EmDash CMS - Self-Hosted for Astro" description="Fully self-hosted CMS for Astro. No cloud required, fully local, with admin panel, authentication, and plugin system.">
<main class="landing">
<!-- Hero Section -->
<section class="hero">
<div class="hero-content">
<div class="badge">Open Source • 10k+ Stars</div>
<h1 class="hero-title">
The CMS that<br />
<span class="accent">runs on your server</span>
</h1>
<p class="hero-subtitle">
EmDash is a full-stack TypeScript CMS built on Astro.
No cloud account required, no external dependencies.
Just a complete admin panel and your content.
</p>
<div class="hero-actions">
<a href="/_emdash/admin" class="btn btn-primary">
<span class="btn-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 21V9"/>
</svg>
</span>
Open Admin Panel
</a>
<a href="https://github.com/emdash-cms/emdash" class="btn btn-secondary" target="_blank">
View on GitHub
</a>
</div>
</div>
<div class="hero-visual">
<div class="code-window">
<div class="code-header">
<span class="dot red"></span>
<span class="dot yellow"></span>
<span class="dot green"></span>
<span class="code-title">astro.config.mjs</span>
</div>
<pre class="code-content"><code><span class="keyword">import</span> emdash <span class="keyword">from</span> <span class="string">"emdash/astro"</span>;
<span class="keyword">import</span> &#123; betterSqlite &#125; <span class="keyword">from</span> <span class="string">"emdash/db"</span>;
<span class="keyword">export default</span> defineConfig(&#123;
<span class="property">integrations</span>: [
emdash(&#123;
<span class="property">database</span>: betterSqlite(&#123;
<span class="property">databasePath</span>: <span class="string">"./data.db"</span>
&#125;),
&#125;),
],
&#125;);</code></pre>
</div>
</div>
</section>
<!-- Features Section -->
<section class="features" id="features">
<div class="section-header">
<h2 class="section-title">Everything you need</h2>
<p class="section-subtitle">A complete CMS without the vendor lock-in</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>
</div>
<h3 class="feature-title">Fully Self-Hosted</h3>
<p class="feature-desc">
SQLite, D1, Turso, or PostgreSQL. Your data stays on your servers.
No cloud account required.
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18M9 21V9"/>
</svg>
</div>
<h3 class="feature-title">Admin Panel</h3>
<p class="feature-desc">
Visual schema builder, media library, navigation menus.
Full admin at /_emdash/admin
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
</svg>
</div>
<h3 class="feature-title">Passkey Auth</h3>
<p class="feature-desc">
WebAuthn passkey-first authentication with OAuth and magic link fallbacks.
Role-based access control.
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
</div>
<h3 class="feature-title">Built-in MCP</h3>
<p class="feature-desc">
Model Context Protocol server for AI tools.
Claude and ChatGPT can interact with your site directly.
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
</div>
<h3 class="feature-title">Plugin System</h3>
<p class="feature-desc">
Sandboxed plugins on Cloudflare Workers.
Define capabilities, run safely in isolation.
</p>
</div>
<div class="feature-card">
<div class="feature-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
</div>
<h3 class="feature-title">WordPress Import</h3>
<p class="feature-desc">
Import posts, pages, media, and taxonomies from WXR exports,
REST API, or WordPress.com.
</p>
</div>
</div>
</section>
<!-- Comparison Section -->
<section class="comparison">
<div class="section-header">
<h2 class="section-title">EmDash vs Tina CMS</h2>
<p class="section-subtitle">Both work with Astro, but differ in approach</p>
</div>
<div class="comparison-table">
<div class="comparison-header">
<div class="comparison-cell header">Feature</div>
<div class="comparison-cell header">EmDash</div>
<div class="comparison-cell header">Tina CMS</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Self-hosted</div>
<div class="comparison-cell emdash">Fully local (SQLite)</div>
<div class="comparison-cell tina">Needs Tina Cloud</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Admin URL</div>
<div class="comparison-cell emdash">/_emdash/admin</div>
<div class="comparison-cell tina">/admin</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Database</div>
<div class="comparison-cell emdash">SQLite, D1, PostgreSQL</div>
<div class="comparison-cell tina">Git-based</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Setup</div>
<div class="comparison-cell emdash">Template-based</div>
<div class="comparison-cell tina">Manual config</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Auth</div>
<div class="comparison-cell emdash">Passkey + OAuth</div>
<div class="comparison-cell tina">Git-based</div>
</div>
<div class="comparison-row">
<div class="comparison-cell label">Price</div>
<div class="comparison-cell emdash">Free (open source)</div>
<div class="comparison-cell tina">Free tier + paid plans</div>
</div>
</div>
</section>
<!-- CTA Section -->
<section class="cta">
<div class="cta-content">
<h2 class="cta-title">Ready to get started?</h2>
<p class="cta-subtitle">
Clone the template, run bootstrap, and you're ready to build.
</p>
<div class="cta-actions">
<a href="/_emdash/admin" class="btn btn-primary btn-large">
Try Admin Panel
</a>
</div>
</div>
</section>
</main>
</Base>
<style>
.landing {
--color-bg: #0a0a0f;
--color-surface: #14141f;
--color-surface-hover: #1a1a2e;
--color-border: #2a2a3e;
--color-text: #f0f0f5;
--color-text-secondary: #a0a0b0;
--color-muted: #6a6a80;
--color-accent: #6366f1;
--color-accent-hover: #818cf8;
--color-emdash: #10b981;
--color-tina: #f59e0b;
background: var(--color-bg);
color: var(--color-text);
min-height: 100vh;
}
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
max-width: 1200px;
margin: 0 auto;
padding: 6rem 2rem;
align-items: center;
}
.badge {
display: inline-block;
padding: 0.5rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 2rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-bottom: 1.5rem;
}
.hero-title {
font-size: 3.5rem;
font-weight: 700;
line-height: 1.1;
margin-bottom: 1.5rem;
letter-spacing: -0.02em;
}
.accent {
color: var(--color-emdash);
}
.hero-subtitle {
font-size: 1.25rem;
line-height: 1.7;
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
.hero-actions {
display: flex;
gap: 1rem;
}
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--color-emdash);
color: #0a0a0f;
}
.btn-primary:hover {
background: #059669;
}
.btn-secondary {
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background: var(--color-surface-hover);
}
.btn-large {
padding: 1rem 2rem;
font-size: 1.125rem;
}
.code-window {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.75rem;
overflow: hidden;
}
.code-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
}
.dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.dot.red { background: #ef4444; }
.dot.yellow { background: #eab308; }
.dot.green { background: #22c55e; }
.code-title {
margin-left: auto;
font-size: 0.75rem;
color: var(--color-muted);
}
.code-content {
padding: 1.5rem;
margin: 0;
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-size: 0.875rem;
line-height: 1.6;
overflow-x: auto;
}
.keyword { color: #c678dd; }
.string { color: #98c379; }
.property { color: #e5c07b; }
.features {
padding: 6rem 2rem;
max-width: 1200px;
margin: 0 auto;
}
.section-header {
text-align: center;
margin-bottom: 4rem;
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.section-subtitle {
font-size: 1.25rem;
color: var(--color-text-secondary);
}
.features-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
}
.feature-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 1rem;
padding: 2rem;
transition: all 0.2s;
}
.feature-card:hover {
border-color: var(--color-emdash);
transform: translateY(-4px);
}
.feature-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-emdash), #059669);
border-radius: 0.75rem;
margin-bottom: 1.5rem;
color: white;
}
.feature-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.feature-desc {
color: var(--color-text-secondary);
line-height: 1.6;
}
.comparison {
padding: 6rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.comparison-table {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 1rem;
overflow: hidden;
}
.comparison-header {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
background: var(--color-surface-hover);
border-bottom: 1px solid var(--color-border);
}
.comparison-row {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
border-bottom: 1px solid var(--color-border);
}
.comparison-row:last-child {
border-bottom: none;
}
.comparison-cell {
padding: 1rem 1.5rem;
font-size: 0.9375rem;
}
.comparison-cell.header {
font-weight: 600;
color: var(--color-muted);
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
}
.comparison-cell.label {
color: var(--color-text-secondary);
}
.comparison-cell.emdash {
color: var(--color-emdash);
font-weight: 500;
}
.comparison-cell.tina {
color: var(--color-tina);
font-weight: 500;
}
.cta {
padding: 8rem 2rem;
text-align: center;
background: linear-gradient(180deg, var(--color-bg) 0%, var(--color-surface) 100%);
}
.cta-content {
max-width: 600px;
margin: 0 auto;
}
.cta-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 1rem;
}
.cta-subtitle {
font-size: 1.25rem;
color: var(--color-text-secondary);
margin-bottom: 2rem;
}
@media (max-width: 900px) {
.hero {
grid-template-columns: 1fr;
padding: 4rem 2rem;
text-align: center;
}
.hero-actions {
justify-content: center;
}
.hero-title {
font-size: 2.5rem;
}
.features-grid {
grid-template-columns: repeat(2, 1fr);
}
.comparison-header,
.comparison-row {
grid-template-columns: 1fr 1fr;
}
.comparison-cell.header:last-child,
.comparison-cell:last-child {
display: none;
}
}
@media (max-width: 600px) {
.hero-title {
font-size: 2rem;
}
.hero-actions {
flex-direction: column;
}
.features-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

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

View File

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

272
src/pages/posts/index.astro Normal file
View File

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

70
src/pages/rss.xml.ts Normal file
View File

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

182
src/pages/search.astro Normal file
View File

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

131
src/pages/tag/[slug].astro Normal file
View File

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