Add combined blog+marketing+portfolio template

- Added blog pages: posts index, post detail, category/tag archives, search, RSS
- Added BlogBase layout with Source Serif 4 font
- Added PostCard and TagList components
- Added utility functions: reading-time, site-identity
- Added posts collection with taxonomies to seed
- Updated menus with all sections: Home, Posts, Work, Pricing, Contact
- Added blog seed content: 3 posts with bylines
This commit is contained in:
Kunthawat Greethong
2026-05-01 13:50:52 +07:00
parent 978bf42e5a
commit 51b50212ca
17 changed files with 1895 additions and 12 deletions

View File

@@ -42,7 +42,7 @@
"runtime": null
},
"build": {
"buildCommand": "pnpm build 2>&1",
"buildCommand": "pnpm build 2>&1 | head -80",
"testCommand": null,
"lintCommand": null,
"devCommand": "npm run dev",
@@ -126,12 +126,30 @@
"lastAccessed": 1777616481729,
"type": "file"
},
{
"path": "src/pages/rss.xml.ts",
"accessCount": 4,
"lastAccessed": 1777618133397,
"type": "file"
},
{
"path": "src/styles/theme.css",
"accessCount": 2,
"lastAccessed": 1777616344900,
"type": "file"
},
{
"path": "src/pages/pages/index.astro",
"accessCount": 2,
"lastAccessed": 1777618173372,
"type": "file"
},
{
"path": "src/pages/posts/index.astro",
"accessCount": 2,
"lastAccessed": 1777618215078,
"type": "file"
},
{
"path": "src/pages/index.astro",
"accessCount": 1,
@@ -185,6 +203,24 @@
"accessCount": 1,
"lastAccessed": 1777616259549,
"type": "file"
},
{
"path": "src/layouts/Base.astro",
"accessCount": 1,
"lastAccessed": 1777617017519,
"type": "file"
},
{
"path": "src/pages/posts/[slug].astro",
"accessCount": 1,
"lastAccessed": 1777618182408,
"type": "file"
},
{
"path": "src/pages/posts/posts-index.astro",
"accessCount": 1,
"lastAccessed": 1777618220299,
"type": "file"
}
],
"userDirectives": []

View File

@@ -1,7 +1,7 @@
{
"tool_name": "Bash",
"tool_input_preview": "{\"command\":\"git remote add origin https://git.moreminimore.com/kunthawat/emdash-marketing-template.git 2>/dev/null || git remote set-url origin https://git.moreminimore.com/kunthawat/emdash-marketing-...",
"error": "Exit code 128",
"timestamp": "2026-04-30T13:58:18.498Z",
"retry_count": 2
"tool_input_preview": "{\"command\":\"pnpm build 2>&1\",\"timeout\":120000,\"description\":\"Build template\"}",
"error": "Exit code 1",
"timestamp": "2026-05-01T06:49:03.571Z",
"retry_count": 1
}

View File

@@ -30,6 +30,19 @@
}
]
},
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions", "search", "seo"],
"commentsEnabled": true,
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
{ "slug": "featured_image", "label": "Featured Image", "type": "image" },
{ "slug": "content", "label": "Content", "type": "portableText", "searchable": true },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" }
]
},
{
"slug": "projects",
"label": "Projects",
@@ -89,9 +102,12 @@
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": false,
"collections": ["projects"],
"hierarchical": true,
"collections": ["posts", "projects"],
"terms": [
{ "slug": "development", "label": "Development" },
{ "slug": "design", "label": "Design" },
{ "slug": "notes", "label": "Notes" },
{ "slug": "branding", "label": "Branding" },
{ "slug": "web", "label": "Web Design" },
{ "slug": "print", "label": "Print" },
@@ -103,8 +119,12 @@
"label": "Tags",
"labelSingular": "Tag",
"hierarchical": false,
"collections": ["projects"],
"collections": ["posts", "projects"],
"terms": [
{ "slug": "webdev", "label": "Web Development" },
{ "slug": "opinion", "label": "Opinion" },
{ "slug": "tools", "label": "Tools" },
{ "slug": "creativity", "label": "Creativity" },
{ "slug": "identity", "label": "Identity" },
{ "slug": "ui-ux", "label": "UI/UX" },
{ "slug": "development", "label": "Development" },
@@ -115,6 +135,10 @@
]
}
],
"bylines": [
{ "id": "byline-editorial", "slug": "emdash-editorial", "displayName": "EmDash Editorial" },
{ "id": "byline-guest", "slug": "guest-contributor", "displayName": "Guest Contributor", "isGuest": true }
],
"menus": [
{
"name": "primary",
@@ -122,19 +146,24 @@
"items": [
{
"type": "custom",
"label": "Features",
"url": "/#features"
"label": "Home",
"url": "/"
},
{
"type": "custom",
"label": "Pricing",
"url": "/pricing"
"label": "Posts",
"url": "/posts"
},
{
"type": "custom",
"label": "Work",
"url": "/work"
},
{
"type": "custom",
"label": "Pricing",
"url": "/pricing"
},
{
"type": "custom",
"label": "Contact",
@@ -632,6 +661,68 @@
"tag": ["art-direction", "editorial"]
}
}
],
"posts": [
{
"id": "post-1",
"slug": "building-for-the-long-term",
"status": "published",
"data": {
"title": "Building for the Long Term",
"excerpt": "The frameworks will change. The databases will change. What survives is the clarity of your thinking.",
"featured_image": "https://images.unsplash.com/photo-1461749280684-dccba630e2f6?w=1200&h=800&fit=crop",
"content": [
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Every few years the industry collectively decides that everything we've been doing is wrong and there's a better way. New frameworks, new paradigms, new build tools. The churn is relentless, and if you're not careful, you spend more time migrating than building." }] },
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "I've been writing software long enough to have seen several of these cycles. jQuery to Backbone to Angular to React to whatever comes next. Each transition felt urgent at the time. Looking back, the things that actually mattered were rarely about the framework." }] },
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "What survives" }] },
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "Clean data models survive. Clear boundaries between systems survive. Good naming survives. The decision to keep things simple when you could have made them clever - that definitely survives." }] }
]
},
"bylines": [{ "byline": "byline-editorial" }],
"taxonomies": {
"category": ["development"],
"tag": ["opinion"]
}
},
{
"id": "post-2",
"slug": "the-case-for-static",
"status": "published",
"data": {
"title": "The Case for Static",
"excerpt": "Static sites aren't a step backwards. They're what you get when you take performance and simplicity seriously.",
"featured_image": "https://images.unsplash.com/photo-1499750310107-5fef28a66643?w=1200&h=800&fit=crop",
"content": [
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "There's a certain irony in the fact that the web started static, went dynamic, and is now swinging back toward static again. But the static sites of today aren't the hand-coded HTML pages of 1998. They're generated, optimized, and deployed to edge networks that serve them in milliseconds." }] },
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "The performance argument" }] },
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "A static file served from a CDN is as fast as the web gets. No cold starts, no database queries, no server-side rendering overhead. The Time to First Byte is essentially the network latency to your nearest edge node. You can't beat physics." }] }
]
},
"bylines": [{ "byline": "byline-editorial" }],
"taxonomies": {
"category": ["development"],
"tag": ["webdev", "opinion"]
}
},
{
"id": "post-3",
"slug": "learning-in-public",
"status": "published",
"data": {
"title": "Learning in Public",
"excerpt": "Writing about what you're learning is the fastest way to find out what you don't actually understand.",
"featured_image": "https://images.unsplash.com/photo-1432821596592-e2c18b78144f?w=1200&h=800&fit=crop",
"content": [
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "I started writing about things I was learning not because I had anything original to say, but because I kept forgetting what I'd figured out. The blog posts were notes to my future self, published publicly more out of laziness than courage." }] },
{ "_type": "block", "style": "h2", "children": [{ "_type": "span", "text": "The fear of being wrong" }] },
{ "_type": "block", "style": "normal", "children": [{ "_type": "span", "text": "The biggest barrier isn't time or writing skill. It's the fear of publishing something that turns out to be wrong. But here's the thing: being wrong publicly is one of the most efficient ways to learn. Someone will correct you, often kindly, and you'll remember that correction forever." }] }
]
},
"taxonomies": {
"category": ["notes"],
"tag": ["opinion"]
}
}
]
}
}

View File

@@ -0,0 +1,184 @@
---
import type { MediaValue, ContentBylineCredit } from "emdash";
import { Image } from "emdash/ui";
interface Props {
title: string;
excerpt?: string;
featuredImage?: MediaValue | string;
href: string;
date?: Date;
readingTime?: number;
tags?: Array<{ slug: string; label: string }>;
bylines?: ContentBylineCredit[];
}
const {
title,
excerpt,
featuredImage,
href,
date,
readingTime,
tags,
bylines,
} = Astro.props;
const formattedDate = date
? date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
})
: null;
---
<article class="card">
<a href={href} aria-hidden="true" tabindex="-1">
{featuredImage ? (
<Image
src={featuredImage}
alt=""
width="600"
height="338"
class="card__image"
quality={80}
/>
) : (
<div class="card__image-placeholder" />
)}
</a>
{bylines && bylines.length > 0 && (
<>
{bylines.slice(0, 1).map((credit) => (
<>
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt=""
width="24"
height="24"
class="card__avatar"
/>
)}
<span>{credit.byline.displayName}</span>
</>
))}
{bylines.length > 1 && (
<span
class="card__bylines-extra"
title={bylines
.slice(1)
.map((c) => c.byline.displayName)
.join(", ")}
tabindex="0"
>
+{bylines.length - 1}
</span>
)}
</>
)}
{(formattedDate || readingTime) && (
<div class="card__meta">
{formattedDate && <time datetime={date?.toISOString()}>{formattedDate}</time>}
{formattedDate && readingTime && <span aria-hidden="true">·</span>}
{readingTime && <span>{readingTime} min read</span>}
</div>
)}
<h2 class="card__title">
<a href={href}>{title}</a>
</h2>
{excerpt && (
<p class="card__excerpt">
<a href={href} aria-hidden="true" tabindex="-1">{excerpt}</a>
</p>
)}
{tags && tags.length > 0 && (
<div class="card__tags">
{tags.slice(0, 2).map((tag) => (
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}
</div>
)}
</article>
<style>
.card {
display: flex;
flex-direction: column;
gap: var(--spacing-2);
}
.card__image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius);
}
.card__image-placeholder {
width: 100%;
aspect-ratio: 16 / 9;
background: var(--color-surface);
border-radius: var(--radius);
}
.card__avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.card__meta {
display: flex;
gap: var(--spacing-2);
font-size: var(--font-size-sm);
color: var(--color-muted);
}
.card__title {
font-size: var(--font-size-xl);
font-weight: 600;
line-height: var(--leading-snug);
}
.card__title a {
color: var(--color-text);
text-decoration: none;
}
.card__title a:hover {
color: var(--color-primary);
}
.card__excerpt {
color: var(--color-muted);
font-size: var(--font-size-base);
line-height: var(--leading-normal);
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card__tags {
display: flex;
gap: var(--spacing-2);
flex-wrap: wrap;
}
.card__tags a {
font-size: var(--font-size-xs);
padding: var(--spacing-1) var(--spacing-2);
background: var(--color-surface);
color: var(--color-text-secondary);
border-radius: var(--radius);
text-decoration: none;
transition: background var(--transition-fast);
}
.card__tags a:hover {
background: var(--color-primary);
color: white;
}
</style>

View File

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

233
src/layouts/BlogBase.astro Normal file
View File

@@ -0,0 +1,233 @@
---
import { getMenu, getEmDashCollection, getSiteSettings } 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/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;
content?: {
collection: string;
id: string;
slug?: string | null;
};
}
const {
title,
pageTitle,
description,
image,
canonical,
robots,
type = "website",
publishedTime,
modifiedTime,
author,
content,
} = Astro.props;
const { siteTitle, siteTagline, siteLogo, siteFavicon } = resolveBlogSiteIdentity(await getSiteSettings());
const fullTitle = title.includes(siteTitle) ? title : `${title} — ${siteTitle}`;
const menu = await getMenu("primary");
const { entries: pages } = await getEmDashCollection("pages");
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,
});
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" />
<title>{fullTitle}</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
{siteFavicon && <link rel="icon" href={siteFavicon} />}
{description && <meta name="description" content={description} />}
<EmDashHead page={pageCtx} />
</head>
<body>
<EmDashBodyStart />
<header class="site-header">
<nav class="container nav">
<a href="/" class="logo">
{siteLogo ? (
<img src={siteLogo.url} alt={siteLogo.alt || siteTitle} width="32" height="32" />
) : (
<span>{siteTitle}</span>
)}
</a>
<ul class="nav-links">
{menu?.items.map((item: any) => (
<li>
<a href={item.url}>{item.label}</a>
</li>
))}
</ul>
<LiveSearch client:load />
{isLoggedIn && <a href="/_emdash/admin" class="admin-link">Admin</a>}
</nav>
</header>
<main>
<slot />
</main>
<footer class="site-footer">
<div class="container footer-content">
<div class="footer-nav">
<a href="/">Home</a>
<a href="/posts">Posts</a>
{pages.map((page) => (
<a href={`/pages/${page.id}`}>{page.data.title}</a>
))}
</div>
<p class="copyright">&copy; {new Date().getFullYear()} {siteTitle}. All rights reserved.</p>
</div>
</footer>
<EmDashBodyEnd />
</body>
</html>
<style>
:global(*) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:global(body) {
font-family: var(--font-sans, "Inter", ui-sans-serif, system-ui, sans-serif);
background: var(--color-bg, #ffffff);
color: var(--color-text, #1a1a1a);
line-height: 1.6;
}
.site-header {
position: sticky;
top: 0;
z-index: 100;
background: var(--color-bg, #ffffff);
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-4, 1rem) 0;
gap: var(--spacing-4, 1rem);
}
.logo {
font-weight: 700;
font-size: var(--font-size-xl, 1.25rem);
text-decoration: none;
color: var(--color-text, #1a1a1a);
}
.nav-links {
display: flex;
gap: var(--spacing-6, 1.5rem);
list-style: none;
}
.nav-links a {
text-decoration: none;
color: var(--color-text-secondary, #525252);
font-weight: 500;
font-size: var(--font-size-sm, 0.875rem);
transition: color var(--transition-fast, 120ms ease);
}
.nav-links a:hover {
color: var(--color-primary, #0066cc);
}
.admin-link {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-primary, #0066cc);
text-decoration: none;
}
main {
min-height: calc(100vh - 200px);
}
.site-footer {
border-top: 1px solid var(--color-border, #e5e5e5);
padding: var(--spacing-12, 3rem) 0;
margin-top: var(--spacing-16, 4rem);
}
.footer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-4, 1rem);
text-align: center;
}
.footer-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--spacing-4, 1rem);
}
.footer-nav a {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #525252);
text-decoration: none;
}
.footer-nav a:hover {
color: var(--color-primary, #0066cc);
}
.copyright {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #8b8b8b);
}
@media (max-width: 768px) {
.nav {
flex-direction: column;
gap: var(--spacing-3, 0.75rem);
}
.nav-links {
flex-wrap: wrap;
justify-content: center;
}
}
</style>

View File

@@ -0,0 +1,89 @@
---
import { getTerm, getEmDashCollection, getTermsForEntries, decodeSlug } from "emdash";
import BlogBase from "../../layouts/BlogBase.astro";
import PostCard from "../../components/PostCard.astro";
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);
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<BlogBase title={`Category: ${term.label}`} description={`Posts in category "${term.label}"`}>
<div class="archive-page">
<h1>Category: {term.label}</h1>
{filteredPosts.length === 0 ? (
<p class="empty">No posts in this category yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
post={post}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
bylines={post.data.bylines ?? []}
href={`/posts/${post.id}`}
/>
))}
</div>
)}
</div>
</BlogBase>
<style>
.archive-page {
max-width: var(--wide-width, 1200px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-8, 2rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.empty {
color: var(--color-muted, #8b8b8b);
padding: var(--spacing-12, 3rem) 0;
text-align: center;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8, 2rem);
}
@media (max-width: 1024px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,57 @@
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import BlogBase from "../../layouts/BlogBase.astro";
const slug = Astro.params.slug as string;
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);
if (!page) {
return Astro.redirect("/404");
}
Astro.cache.set(cacheHint);
---
<BlogBase
title={page.data.title}
description={page.data.title}
content={{ collection: "pages", id: page.id }}
>
<article class="page-article">
<h1>{page.data.title}</h1>
<div class="page-content">
<PortableText value={page.data.content} />
</div>
</article>
</BlogBase>
<style>
.page-article {
max-width: var(--content-width, 680px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-8, 2rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.page-content {
font-size: var(--font-size-lg, 1.125rem);
line-height: var(--leading-relaxed, 1.7);
}
.page-content :global(p) {
margin-bottom: var(--spacing-6, 1.5rem);
}
.page-content :global(h2) {
font-size: var(--font-size-2xl, 1.5rem);
margin-top: var(--spacing-10, 2.5rem);
margin-bottom: var(--spacing-4, 1rem);
}
</style>

View File

@@ -0,0 +1,66 @@
---
import { getEmDashCollection } from "emdash";
import BlogBase from "../../layouts/BlogBase.astro";
const { entries: pages, cacheHint } = await getEmDashCollection("pages");
Astro.cache.set(cacheHint);
---
<BlogBase title="Pages" description="All pages">
<div class="pages-page">
<h1>Pages</h1>
{pages.length === 0 ? (
<p class="empty">No pages yet.</p>
) : (
<ul class="pages-list">
{pages.map((page) => (
<li>
<a href={`/pages/${page.id}`}>{page.data.title}</a>
</li>
))}
</ul>
)}
</div>
</BlogBase>
<style>
.pages-page {
max-width: var(--content-width, 680px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-8, 2rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.empty {
color: var(--color-muted, #8b8b8b);
}
.pages-list {
list-style: none;
padding: 0;
margin: 0;
}
.pages-list li {
padding: var(--spacing-4, 1rem) 0;
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.pages-list a {
font-size: var(--font-size-lg, 1.125rem);
color: var(--color-text, #1a1a1a);
text-decoration: none;
font-weight: 500;
}
.pages-list a:hover {
color: var(--color-primary, #0066cc);
}
</style>

View File

@@ -0,0 +1,246 @@
---
import { getEmDashEntry, getEmDashCollection, getTermsForEntries, getSeoMeta } from "emdash";
import { Image, PortableText } from "emdash/ui";
import BlogBase from "../../layouts/BlogBase.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
const slug = Astro.params.slug as string;
const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
if (!post) {
return Astro.redirect("/404");
}
Astro.cache.set(cacheHint);
const [{ entries: recentPosts }, tagsResult] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 4,
}),
getTermsForEntries("posts", [post.data.id], "tag"),
]);
const otherPosts = recentPosts.filter((p) => p.id !== post.id).slice(0, 3);
const postTags = (tagsResult.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}));
const seoMeta = getSeoMeta({
title: post.data.title,
description: post.data.excerpt,
image: post.data.featured_image,
publishedTime: post.data.publishedAt?.toISOString(),
author: post.data.bylines?.[0]?.byline.displayName,
});
function formatDate(date: Date) {
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<BlogBase
title={post.data.title}
description={post.data.excerpt}
image={post.data.featured_image as string}
type="article"
publishedTime={post.data.publishedAt?.toISOString()}
author={post.data.bylines?.[0]?.byline.displayName}
content={{ collection: "posts", id: post.id, slug }}
>
<article class="post-article">
{post.data.featured_image && (
<div class="hero-image">
<Image
src={post.data.featured_image}
alt={post.data.title}
width="1200"
height="675"
/>
</div>
)}
<div class="post-content">
<header class="post-header">
{post.data.bylines && post.data.bylines.length > 0 && (
<div class="bylines">
{post.data.bylines.map((credit: any) => (
<span class="byline-credit">
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="byline-avatar"
/>
)}
<span>{credit.byline.displayName}</span>
</span>
))}
</div>
)}
{post.data.publishedAt && (
<time datetime={post.data.publishedAt.toISOString()}>
{formatDate(post.data.publishedAt)}
</time>
)}
<span>{getReadingTime(post.data.content)} min read</span>
{postTags.length > 0 && (
<div class="tags">
{postTags.map((tag) => (
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}
</div>
)}
</header>
<div class="post-body">
<PortableText value={post.data.content} />
</div>
{otherPosts.length > 0 && (
<section class="related-posts">
<h2>Related Posts</h2>
<div class="related-grid">
{otherPosts.map((p) => (
<PostCard
post={p}
tags={[]}
bylines={p.data.bylines ?? []}
href={`/posts/${p.id}`}
/>
))}
</div>
</section>
)}
</div>
</article>
</BlogBase>
<style>
.post-article {
max-width: var(--content-width, 680px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
.hero-image {
margin-bottom: var(--spacing-8, 2rem);
border-radius: var(--radius-lg, 8px);
overflow: hidden;
}
.hero-image img {
width: 100%;
height: auto;
}
.post-header {
display: flex;
flex-direction: column;
gap: var(--spacing-2, 0.5rem);
margin-bottom: var(--spacing-8, 2rem);
padding-bottom: var(--spacing-6, 1.5rem);
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.bylines {
display: flex;
gap: var(--spacing-3, 0.75rem);
}
.byline-credit {
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #525252);
}
.byline-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
}
.post-header time {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #8b8b8b);
}
.post-header .tags {
display: flex;
gap: var(--spacing-2, 0.5rem);
flex-wrap: wrap;
}
.post-header .tags a {
font-size: var(--font-size-xs, 0.8125rem);
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
background: var(--color-surface, #f7f7f7);
color: var(--color-text-secondary, #525252);
border-radius: var(--radius, 4px);
text-decoration: none;
transition: all var(--transition-fast, 120ms ease);
}
.post-header .tags a:hover {
background: var(--color-primary, #0066cc);
color: white;
}
.post-body {
font-size: var(--font-size-lg, 1.125rem);
line-height: var(--leading-relaxed, 1.7);
}
.post-body :global(h2) {
font-size: var(--font-size-2xl, 1.5rem);
margin-top: var(--spacing-10, 2.5rem);
margin-bottom: var(--spacing-4, 1rem);
}
.post-body :global(p) {
margin-bottom: var(--spacing-6, 1.5rem);
}
.post-body :global(a) {
color: var(--color-primary, #0066cc);
}
.post-body :global(blockquote) {
border-left: 4px solid var(--color-primary, #0066cc);
padding-left: var(--spacing-4, 1rem);
margin: var(--spacing-6, 1.5rem) 0;
font-style: italic;
color: var(--color-text-secondary, #525252);
}
.related-posts {
margin-top: var(--spacing-16, 4rem);
padding-top: var(--spacing-12, 3rem);
border-top: 1px solid var(--color-border, #e5e5e5);
}
.related-posts h2 {
font-size: var(--font-size-2xl, 1.5rem);
margin-bottom: var(--spacing-8, 2rem);
}
.related-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-6, 1.5rem);
}
@media (max-width: 768px) {
.related-grid {
grid-template-columns: 1fr;
}
}
</style>

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

@@ -0,0 +1,268 @@
---
import { getEmDashCollection, getTermsForEntries, getSiteSettings } from "emdash";
import { Image } from "emdash/ui";
import BlogBase from "../../layouts/BlogBase.astro";
import PostCard from "../../components/PostCard.astro";
import { getReadingTime } from "../../utils/reading-time";
import { resolveBlogSiteIdentity } from "../../utils/site-identity";
const POSTS_PER_PAGE = 7;
const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1,
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);
Astro.cache.set(cacheHint);
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);
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 ?? [],
}));
function formatDate(date: Date | null | undefined) {
if (!date) return null;
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
}
---
<BlogBase title={siteTitle} description={siteTagline}>
{posts.length === 0 ? (
<div class="empty-state">
<h2>No posts yet</h2>
<p>Create your first post in the admin panel.</p>
<a href="/_emdash/admin">Open Admin</a>
</div>
) : (
<div class="posts-page">
{featuredPost && (
<div class="featured-post">
<a href={`/posts/${featuredPost.id}`} class="featured-image">
<Image asset={featuredPost.data.featured_image} alt="" width="800" height="450" />
</a>
<div class="featured-content">
{featuredBylines.length > 0 && (
<div class="bylines">
{featuredBylines.slice(0, 2).map((credit, index) => (
<span class="byline-credit">
{index > 0 && <span class="separator">,</span>}
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
class="byline-avatar"
/>
)}
<span>{credit.byline.displayName}</span>
</span>
))}
{featuredBylines.length > 2 && (
<span class="byline-overflow">+{featuredBylines.length - 2}</span>
)}
</div>
)}
{formatDate(featuredPost.data.publishedAt) && (
<time>{formatDate(featuredPost.data.publishedAt)}</time>
)}
<span>{getReadingTime(featuredPost.data.content)} min read</span>
<h1>
<a href={`/posts/${featuredPost.id}`}>{featuredPost.data.title}</a>
</h1>
{featuredPost.data.excerpt && (
<p>{featuredPost.data.excerpt}</p>
)}
{featuredTags.length > 0 && (
<div class="tags">
{featuredTags.map((tag) => (
<a href={`/tag/${tag.slug}`}>{tag.label}</a>
))}
</div>
)}
</div>
</div>
)}
{gridPostsWithTags.length > 0 && (
<div class="posts-grid">
{gridPostsWithTags.map(({ post, tags, bylines }) => (
<PostCard
post={post}
tags={tags}
bylines={bylines}
href={`/posts/${post.id}`}
/>
))}
</div>
)}
</div>
)}
</BlogBase>
<style>
.empty-state {
text-align: center;
padding: var(--spacing-20, 5rem) var(--spacing-4, 1rem);
}
.empty-state a {
display: inline-block;
margin-top: var(--spacing-4, 1rem);
padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
background: var(--color-primary, #0066cc);
color: white;
text-decoration: none;
border-radius: var(--radius, 4px);
font-weight: 600;
}
.posts-page {
max-width: var(--wide-width, 1200px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
.featured-post {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--spacing-8, 2rem);
margin-bottom: var(--spacing-12, 3rem);
padding-bottom: var(--spacing-12, 3rem);
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.featured-image {
display: block;
border-radius: var(--radius-lg, 8px);
overflow: hidden;
}
.featured-image img {
width: 100%;
height: auto;
object-fit: cover;
}
.featured-content {
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--spacing-2, 0.5rem);
}
.featured-content .bylines {
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-text-secondary, #525252);
}
.byline-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
}
.featured-content time {
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #8b8b8b);
}
.featured-content h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
line-height: var(--leading-tight, 1.15);
letter-spacing: var(--tracking-tight, -0.03em);
}
.featured-content h1 a {
color: var(--color-text, #1a1a1a);
text-decoration: none;
}
.featured-content h1 a:hover {
color: var(--color-primary, #0066cc);
}
.featured-content p {
font-size: var(--font-size-lg, 1.125rem);
color: var(--color-text-secondary, #525252);
line-height: var(--leading-relaxed, 1.7);
}
.featured-content .tags {
display: flex;
gap: var(--spacing-2, 0.5rem);
flex-wrap: wrap;
}
.featured-content .tags a {
font-size: var(--font-size-xs, 0.8125rem);
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
background: var(--color-surface, #f7f7f7);
color: var(--color-text-secondary, #525252);
border-radius: var(--radius, 4px);
text-decoration: none;
transition: all var(--transition-fast, 120ms ease);
}
.featured-content .tags a:hover {
background: var(--color-primary, #0066cc);
color: white;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8, 2rem);
}
@media (max-width: 1024px) {
.featured-post {
grid-template-columns: 1fr;
}
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,198 @@
---
import { getEmDashCollection, getTermsForEntries } from "emdash";
import BlogBase from "../../layouts/BlogBase.astro";
import { getReadingTime } from "../../utils/reading-time";
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
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",
});
---
<BlogBase title="All Posts" description="Browse all posts">
<div class="posts-list-page">
<h1>All Posts</h1>
{posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="post-list">
{postsWithTags.map(({ post, tags, bylines }) => (
<article class="post-card">
<header>
{bylines.length > 0 && (
<div class="byline-avatars">
{bylines.slice(0, 2).map((credit, index) => (
<>
{index > 0 && <span>, </span>}
{credit.byline.avatarMediaId && (
<img
src={`/_emdash/api/media/file/${credit.byline.avatarMediaId}`}
alt={credit.byline.displayName}
/>
)}
{credit.byline.displayName}
</>
))}
{bylines.length > 2 && (
<span>+{bylines.length - 2}</span>
)}
</div>
)}
{post.data.publishedAt && (
<time datetime={post.data.publishedAt.toISOString()}>
{formatDate(post.data.publishedAt)}
</time>
)}
<span>{getReadingTime(post.data.content)} min read</span>
</header>
<h2>
<a href={`/posts/${post.id}`}>{post.data.title}</a>
</h2>
<hr />
{post.data.excerpt && (
<p class="post-excerpt">{post.data.excerpt}</p>
)}
<a href={`/posts/${post.id}`} class="read-more">Read more →</a>
{tags.length > 0 && (
<div class="tag-list">
{tags.slice(0, 3).map((t) => (
<a href={`/tag/${t.slug}`} class="tag">{t.label}</a>
))}
</div>
)}
</article>
))}
</div>
)}
</div>
</BlogBase>
<style>
.posts-list-page {
max-width: var(--content-width, 680px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-8, 2rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.empty {
color: var(--color-muted, #8b8b8b);
padding: var(--spacing-12, 3rem) 0;
text-align: center;
}
.post-card {
padding: var(--spacing-6, 1.5rem) 0;
border-bottom: 1px solid var(--color-border, #e5e5e5);
}
.post-card header {
display: flex;
gap: var(--spacing-3, 0.75rem);
align-items: center;
flex-wrap: wrap;
font-size: var(--font-size-sm, 0.875rem);
color: var(--color-muted, #8b8b8b);
margin-bottom: var(--spacing-2, 0.5rem);
}
.byline-avatars {
display: flex;
align-items: center;
gap: var(--spacing-2, 0.5rem);
}
.byline-avatars img {
width: 20px;
height: 20px;
border-radius: 50%;
}
.post-card h2 {
font-size: var(--font-size-2xl, 1.5rem);
font-weight: 600;
line-height: var(--leading-snug, 1.3);
letter-spacing: var(--tracking-snug, -0.02em);
margin-bottom: var(--spacing-2, 0.5rem);
}
.post-card h2 a {
color: var(--color-text, #1a1a1a);
text-decoration: none;
}
.post-card h2 a:hover {
color: var(--color-primary, #0066cc);
}
.post-card hr {
border: none;
border-top: 1px solid var(--color-border, #e5e5e5);
margin: var(--spacing-4, 1rem) 0;
}
.post-excerpt {
color: var(--color-text-secondary, #525252);
line-height: var(--leading-normal, 1.5);
margin-bottom: var(--spacing-4, 1rem);
}
.read-more {
display: inline-block;
color: var(--color-primary, #0066cc);
text-decoration: none;
font-weight: 500;
margin-bottom: var(--spacing-4, 1rem);
}
.read-more:hover {
text-decoration: underline;
}
.tag-list {
display: flex;
gap: var(--spacing-2, 0.5rem);
flex-wrap: wrap;
}
.tag {
font-size: var(--font-size-xs, 0.8125rem);
padding: var(--spacing-1, 0.25rem) var(--spacing-2, 0.5rem);
background: var(--color-surface, #f7f7f7);
color: var(--color-text-secondary, #525252);
border-radius: var(--radius, 4px);
text-decoration: none;
transition: all var(--transition-fast, 120ms ease);
}
.tag:hover {
background: var(--color-primary, #0066cc);
color: white;
}
</style>

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

@@ -0,0 +1,68 @@
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>
<language>en-us</language>
<lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
<atom:link href="${siteUrl}/rss.xml" rel="self" type="application/rss+xml" />
${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;
}

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

@@ -0,0 +1,141 @@
---
import { getEmDashCollection } from "emdash";
import BlogBase from "../layouts/BlogBase.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
const query = Astro.url.searchParams.get("q")?.trim() || "";
let results: any[] = [];
if (query) {
const { entries, cacheHint } = await getEmDashCollection("posts", {
search: query,
orderBy: { published_at: "desc" },
});
Astro.cache.set(cacheHint);
results = entries;
}
---
<BlogBase title="Search" description="Search posts">
<div class="search-page">
<h1>Search</h1>
<form class="search-form" method="get">
<input
type="search"
name="q"
value={query}
placeholder="Search posts..."
autofocus
/>
<button type="submit">Search</button>
</form>
{query ? (
results.length > 0 ? (
<p class="result-count">{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"</p>
) : (
<p class="no-results">No results found for "{query}"</p>
)
) : (
<p class="prompt">Enter a search term to find posts</p>
)}
{results.length > 0 && (
<div class="results-grid">
{results.map((post) => (
<PostCard
post={post}
tags={[]}
bylines={post.data.bylines ?? []}
href={`/posts/${post.id}`}
/>
))}
</div>
)}
</div>
</BlogBase>
<style>
.search-page {
max-width: var(--wide-width, 1200px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-6, 1.5rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.search-form {
display: flex;
gap: var(--spacing-3, 0.75rem);
margin-bottom: var(--spacing-8, 2rem);
}
.search-form input {
flex: 1;
max-width: 400px;
padding: var(--spacing-3, 0.75rem) var(--spacing-4, 1rem);
font-size: var(--font-size-base, 1rem);
border: 1px solid var(--color-border, #e5e5e5);
border-radius: var(--radius, 4px);
background: var(--color-bg, #ffffff);
color: var(--color-text, #1a1a1a);
}
.search-form input:focus {
outline: none;
border-color: var(--color-primary, #0066cc);
}
.search-form button {
padding: var(--spacing-3, 0.75rem) var(--spacing-6, 1.5rem);
font-size: var(--font-size-base, 1rem);
font-weight: 600;
background: var(--color-primary, #0066cc);
color: white;
border: none;
border-radius: var(--radius, 4px);
cursor: pointer;
transition: background var(--transition-fast, 120ms ease);
}
.search-form button:hover {
background: var(--color-primary-hover, #0052a3);
}
.result-count {
color: var(--color-text-secondary, #525252);
margin-bottom: var(--spacing-6, 1.5rem);
}
.no-results,
.prompt {
color: var(--color-muted, #8b8b8b);
padding: var(--spacing-12, 3rem) 0;
text-align: center;
}
.results-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8, 2rem);
}
@media (max-width: 1024px) {
.results-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.results-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,89 @@
---
import { getTerm, getEmDashCollection, getTermsForEntries, decodeSlug } from "emdash";
import BlogBase from "../../layouts/BlogBase.astro";
import PostCard from "../../components/PostCard.astro";
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);
const tagsByEntry = await getTermsForEntries(
"posts",
posts.map((p) => p.data.id),
"tag",
);
const filteredPosts = posts.map((post) => ({
post,
tags: tagsByEntry.get(post.data.id) ?? [],
}));
---
<BlogBase title={`Tag: ${term.label}`} description={`Posts tagged with "${term.label}"`}>
<div class="archive-page">
<h1>Posts tagged: {term.label}</h1>
{filteredPosts.length === 0 ? (
<p class="empty">No posts with this tag yet.</p>
) : (
<div class="posts-grid">
{filteredPosts.map(({ post, tags }) => (
<PostCard
post={post}
tags={tags.map((t) => ({ slug: t.slug, label: t.label }))}
bylines={post.data.bylines ?? []}
href={`/posts/${post.id}`}
/>
))}
</div>
)}
</div>
</BlogBase>
<style>
.archive-page {
max-width: var(--wide-width, 1200px);
margin: 0 auto;
padding: var(--spacing-8, 2rem);
}
h1 {
font-size: var(--font-size-4xl, 2.5rem);
font-weight: 700;
margin-bottom: var(--spacing-8, 2rem);
letter-spacing: var(--tracking-tight, -0.03em);
}
.empty {
color: var(--color-muted, #8b8b8b);
padding: var(--spacing-12, 3rem) 0;
text-align: center;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--spacing-8, 2rem);
}
@media (max-width: 1024px) {
.posts-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.posts-grid {
grid-template-columns: 1fr;
}
}
</style>

54
src/utils/reading-time.ts Normal file
View File

@@ -0,0 +1,54 @@
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;
}
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(" ");
}
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);
}
export function formatReadingTime(minutes: number): string {
return `${minutes} min read`;
}

View File

@@ -0,0 +1,20 @@
import type { MediaReference } from "emdash";
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,
siteFavicon: settings?.favicon?.url ?? null,
};
}