diff --git a/.omc/project-memory.json b/.omc/project-memory.json
index ddac9e6..3f17b3b 100644
--- a/.omc/project-memory.json
+++ b/.omc/project-memory.json
@@ -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": []
diff --git a/.omc/state/last-tool-error.json b/.omc/state/last-tool-error.json
index 0032f73..1009281 100644
--- a/.omc/state/last-tool-error.json
+++ b/.omc/state/last-tool-error.json
@@ -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
}
\ No newline at end of file
diff --git a/seed/seed.json b/seed/seed.json
index b51d92e..6e2144a 100644
--- a/seed/seed.json
+++ b/seed/seed.json
@@ -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"]
+ }
+ }
]
}
}
\ No newline at end of file
diff --git a/src/components/PostCard.astro b/src/components/PostCard.astro
new file mode 100644
index 0000000..5d9054a
--- /dev/null
+++ b/src/components/PostCard.astro
@@ -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;
+---
+
+
+
+ {featuredImage ? (
+
+ ) : (
+
+ )}
+
+ {bylines && bylines.length > 0 && (
+ <>
+ {bylines.slice(0, 1).map((credit) => (
+ <>
+ {credit.byline.avatarMediaId && (
+
+ )}
+ {credit.byline.displayName}
+ >
+ ))}
+ {bylines.length > 1 && (
+
+ )}
+ >
+ )}
+ {(formattedDate || readingTime) && (
+
+ {formattedDate && }
+ {formattedDate && readingTime && ·}
+ {readingTime && {readingTime} min read}
+
+ )}
+
+ {excerpt && (
+
+ {excerpt}
+
+ )}
+ {tags && tags.length > 0 && (
+
+ )}
+
+
+
\ No newline at end of file
diff --git a/src/components/TagList.astro b/src/components/TagList.astro
new file mode 100644
index 0000000..dc955f1
--- /dev/null
+++ b/src/components/TagList.astro
@@ -0,0 +1,43 @@
+---
+interface Props {
+ tags: Array<{ slug: string; label: string }>;
+ class?: string;
+}
+const { tags, class: className } = Astro.props;
+---
+
+{tags.length > 0 && (
+
+)}
+
+
\ No newline at end of file
diff --git a/src/layouts/BlogBase.astro b/src/layouts/BlogBase.astro
new file mode 100644
index 0000000..295faf9
--- /dev/null
+++ b/src/layouts/BlogBase.astro
@@ -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;
+---
+
+
+
+
+
+
+ {fullTitle}
+
+
+
+ {siteFavicon && }
+ {description && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/category/[slug].astro b/src/pages/category/[slug].astro
new file mode 100644
index 0000000..5cd001c
--- /dev/null
+++ b/src/pages/category/[slug].astro
@@ -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) ?? [],
+}));
+---
+
+
+
+
Category: {term.label}
+
+ {filteredPosts.length === 0 ? (
+
No posts in this category yet.
+ ) : (
+
+ {filteredPosts.map(({ post, tags }) => (
+
({ slug: t.slug, label: t.label }))}
+ bylines={post.data.bylines ?? []}
+ href={`/posts/${post.id}`}
+ />
+ ))}
+
+ )}
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/pages/[slug].astro b/src/pages/pages/[slug].astro
new file mode 100644
index 0000000..8b9441a
--- /dev/null
+++ b/src/pages/pages/[slug].astro
@@ -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);
+---
+
+
+
+ {page.data.title}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/pages/index.astro b/src/pages/pages/index.astro
new file mode 100644
index 0000000..9341fc5
--- /dev/null
+++ b/src/pages/pages/index.astro
@@ -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);
+---
+
+
+
+
Pages
+
+ {pages.length === 0 ? (
+
No pages yet.
+ ) : (
+
+ {pages.map((page) => (
+ -
+ {page.data.title}
+
+ ))}
+
+ )}
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/posts/[slug].astro b/src/pages/posts/[slug].astro
new file mode 100644
index 0000000..f1a9cf2
--- /dev/null
+++ b/src/pages/posts/[slug].astro
@@ -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",
+ });
+}
+---
+
+
+
+ {post.data.featured_image && (
+
+
+
+ )}
+
+
+
+
+
+
+ {otherPosts.length > 0 && (
+
+ )}
+
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/posts/index.astro b/src/pages/posts/index.astro
new file mode 100644
index 0000000..90c3ed8
--- /dev/null
+++ b/src/pages/posts/index.astro
@@ -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",
+ });
+}
+---
+
+
+ {posts.length === 0 ? (
+
+
No posts yet
+
Create your first post in the admin panel.
+
Open Admin
+
+ ) : (
+
+ {featuredPost && (
+
+
+
+
+
+ {featuredBylines.length > 0 && (
+
+ {featuredBylines.slice(0, 2).map((credit, index) => (
+
+ {index > 0 && ,}
+ {credit.byline.avatarMediaId && (
+
+ )}
+ {credit.byline.displayName}
+
+ ))}
+ {featuredBylines.length > 2 && (
+
+{featuredBylines.length - 2}
+ )}
+
+ )}
+ {formatDate(featuredPost.data.publishedAt) && (
+
+ )}
+
{getReadingTime(featuredPost.data.content)} min read
+
+ {featuredPost.data.excerpt && (
+
{featuredPost.data.excerpt}
+ )}
+ {featuredTags.length > 0 && (
+
+ )}
+
+
+ )}
+
+ {gridPostsWithTags.length > 0 && (
+
+ {gridPostsWithTags.map(({ post, tags, bylines }) => (
+
+ ))}
+
+ )}
+
+ )}
+
+
+
\ No newline at end of file
diff --git a/src/pages/posts/posts-index.astro b/src/pages/posts/posts-index.astro
new file mode 100644
index 0000000..7975844
--- /dev/null
+++ b/src/pages/posts/posts-index.astro
@@ -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",
+ });
+---
+
+
+
+
All Posts
+
+ {posts.length === 0 ? (
+
No posts yet.
+ ) : (
+
+ {postsWithTags.map(({ post, tags, bylines }) => (
+
+
+
+
+ {post.data.excerpt && (
+ {post.data.excerpt}
+ )}
+ Read more →
+ {tags.length > 0 && (
+
+ {tags.slice(0, 3).map((t) => (
+
{t.label}
+ ))}
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/rss.xml.ts b/src/pages/rss.xml.ts
new file mode 100644
index 0000000..071f045
--- /dev/null
+++ b/src/pages/rss.xml.ts
@@ -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 `
+ -
+ ${title}
+ ${postUrl}
+ ${postUrl}
+ ${pubDate}
+ ${description}
+
+ `;
+ })
+ .filter(Boolean)
+ .join("\n");
+
+ const rss = `
+
+
+ ${escapeXml(siteTitle)}
+ ${escapeXml(siteTagline)}
+ ${siteUrl}
+ en-us
+ ${new Date().toUTCString()}
+
+ ${items}
+
+`;
+
+ return new Response(rss, {
+ headers: {
+ "Content-Type": "application/rss+xml; charset=utf-8",
+ "Cache-Control": "public, max-age=3600",
+ },
+ });
+};
+
+const XML_ESCAPE_PATTERNS = [
+ [/&/g, "&"],
+ [//g, ">"],
+ [/"/g, """],
+ [/'/g, "'"],
+] 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;
+}
\ No newline at end of file
diff --git a/src/pages/search.astro b/src/pages/search.astro
new file mode 100644
index 0000000..d0fbb46
--- /dev/null
+++ b/src/pages/search.astro
@@ -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;
+}
+---
+
+
+
+
Search
+
+
+
+ {query ? (
+ results.length > 0 ? (
+
{results.length} result{results.length !== 1 ? "s" : ""} for "{query}"
+ ) : (
+
No results found for "{query}"
+ )
+ ) : (
+
Enter a search term to find posts
+ )}
+
+ {results.length > 0 && (
+
+ {results.map((post) => (
+
+ ))}
+
+ )}
+
+
+
+
\ No newline at end of file
diff --git a/src/pages/tag/[slug].astro b/src/pages/tag/[slug].astro
new file mode 100644
index 0000000..eb7594b
--- /dev/null
+++ b/src/pages/tag/[slug].astro
@@ -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) ?? [],
+}));
+---
+
+
+
+
Posts tagged: {term.label}
+
+ {filteredPosts.length === 0 ? (
+
No posts with this tag yet.
+ ) : (
+
+ {filteredPosts.map(({ post, tags }) => (
+
({ slug: t.slug, label: t.label }))}
+ bylines={post.data.bylines ?? []}
+ href={`/posts/${post.id}`}
+ />
+ ))}
+
+ )}
+
+
+
+
\ No newline at end of file
diff --git a/src/utils/reading-time.ts b/src/utils/reading-time.ts
new file mode 100644
index 0000000..638391f
--- /dev/null
+++ b/src/utils/reading-time.ts
@@ -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`;
+}
\ No newline at end of file
diff --git a/src/utils/site-identity.ts b/src/utils/site-identity.ts
new file mode 100644
index 0000000..f87e26f
--- /dev/null
+++ b/src/utils/site-identity.ts
@@ -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,
+ };
+}
\ No newline at end of file