Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion blank/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"@astrojs/react": "^5.0.0",
"astro": "^6.0.1",
"better-sqlite3": "^12.8.0",
"emdash": "^0.7.0",
"emdash": "^0.8.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
Expand Down
4 changes: 2 additions & 2 deletions blog-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
"dependencies": {
"@astrojs/cloudflare": "^13.1.7",
"@astrojs/react": "^5.0.0",
"@emdash-cms/cloudflare": "^0.7.0",
"@emdash-cms/cloudflare": "^0.8.0",
"@emdash-cms/plugin-forms": "^0.1.1",
"@emdash-cms/plugin-webhook-notifier": "^0.1.2",
"astro": "^6.0.1",
"emdash": "^0.7.0",
"emdash": "^0.8.0",
"react": "19.2.4",
"react-dom": "19.2.4"
},
Expand Down
28 changes: 20 additions & 8 deletions blog-cloudflare/src/pages/category/[slug].astro
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
---
import { getTerm, getEmDashCollection, getEntryTerms, decodeSlug } from "emdash";
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";
Expand All @@ -11,18 +16,25 @@ if (!term) {
return Astro.redirect("/404");
}

const { entries: posts } = await getEmDashCollection("posts", {
const { entries: posts, cacheHint } = await getEmDashCollection("posts", {
where: { category: term.slug },
orderBy: { published_at: "desc" },
});

// Fetch tags for display on each post card
const filteredPosts = await Promise.all(
posts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
return { post, tags };
})
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}`}>
Expand Down
81 changes: 47 additions & 34 deletions blog-cloudflare/src/pages/index.astro
Original file line number Diff line number Diff line change
@@ -1,53 +1,66 @@
---
import { getEmDashCollection, getEntryTerms, getSiteSettings } from "emdash";
import {
getEmDashCollection,
getTermsForEntries,
getSiteSettings,
} from "emdash";
import { Image } from "emdash/ui";
import Base from "../layouts/Base.astro";
import PostCard from "../components/PostCard.astro";
import { getReadingTime } from "../utils/reading-time";
import { resolveBlogSiteIdentity } from "../utils/site-identity";

const { entries: posts, cacheHint } = await getEmDashCollection("posts");
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(await getSiteSettings());
// Limit to what we render (1 featured + 6 grid). The DB does the slicing
// instead of fetching every post and discarding the tail in JS.
const POSTS_PER_PAGE = 7;

const [{ entries: posts, cacheHint }, settings] = await Promise.all([
getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: POSTS_PER_PAGE + 1, // +1 to detect "view all" need
}),
getSiteSettings(),
]);
const { siteTitle, siteTagline } = resolveBlogSiteIdentity(settings);

Astro.cache.set(cacheHint);

const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});
// Trim the lookahead post used to detect overflow
const visiblePosts = posts.slice(0, POSTS_PER_PAGE);
const hasMorePosts = posts.length > POSTS_PER_PAGE;

// Find the first post with a featured image for the hero
const featuredPost = sortedPosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? sortedPosts.indexOf(featuredPost) : -1;
const featuredPost = visiblePosts.find((p) => p.data.featured_image);
const featuredIndex = featuredPost ? visiblePosts.indexOf(featuredPost) : -1;

// Get remaining posts (exclude featured if found, limit to 6 for grid)
const gridPosts = sortedPosts.filter((_, i) => i !== featuredIndex).slice(0, 6);

// Total posts shown = featured (if any) + grid posts
const totalShown = (featuredPost ? 1 : 0) + gridPosts.length;
const hasMorePosts = sortedPosts.length > totalShown;

// Fetch tags for featured post (bylines are already hydrated by getEmDashCollection)
let featuredTags: Array<{ slug: string; label: string }> = [];
const gridPosts = visiblePosts.filter((_, i) => i !== featuredIndex).slice(0, 6);

// Single batched query for tags across the featured post + grid posts.
// Avoids the N+1 pattern of calling getEntryTerms() per entry.
// Bylines are already hydrated on entry.data.bylines by getEmDashCollection.
const tagEntryIds = [
...(featuredPost ? [featuredPost.data.id] : []),
...gridPosts.map((p) => p.data.id),
];
const tagsByEntry = await getTermsForEntries("posts", tagEntryIds, "tag");

const featuredTags = featuredPost
? (tagsByEntry.get(featuredPost.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
}))
: [];
const featuredBylines = featuredPost?.data.bylines ?? [];
if (featuredPost) {
const tags = await getEntryTerms("posts", featuredPost.data.id, "tag");
featuredTags = tags.map((t) => ({ slug: t.slug, label: t.label }));
}

// Fetch tags for grid posts (bylines are already hydrated by getEmDashCollection)
const gridPostsWithTags = await Promise.all(
gridPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return {
post,
tags: tags.map((t) => ({ slug: t.slug, label: t.label })),
bylines,
};
})
);
const gridPostsWithTags = gridPosts.map((post) => ({
post,
tags: (tagsByEntry.get(post.data.id) ?? []).map((t) => ({
slug: t.slug,
label: t.label,
})),
bylines: post.data.bylines ?? [],
}));

// Format date helper
function formatDate(date: Date | null | undefined) {
Expand Down
41 changes: 24 additions & 17 deletions blog-cloudflare/src/pages/posts/[slug].astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getEmDashEntry,
getEmDashCollection,
getEntryTerms,
getTermsForEntries,
getSeoMeta,
decodeSlug,
getSiteSettings,
Expand Down Expand Up @@ -65,32 +66,38 @@ const seo = getSeoMeta(post, {
defaultOgImage: featuredImageUrl,
});

// Get tags for this post
// Note: post.id is the slug, post.data.id is the database ULID
const tags = await getEntryTerms("posts", post.data.id, "tag");

// Bylines are already hydrated by getEmDashEntry
const bylines = post.data.bylines ?? [];

// Get reading time
const readingTime = getReadingTime(post.data.content);

// Get other posts for "More posts" section, with their tags
// Fetch a few extra in case the current post is among them
const { entries: recentPosts } = await getEmDashCollection("posts", {
orderBy: { published_at: "desc" },
limit: 4,
});
// 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);

// Fetch tags for related posts (bylines are already hydrated by getEmDashCollection)
const otherPostsWithTags = await Promise.all(
otherPosts.map(async (p) => {
const postTags = await getEntryTerms("posts", p.data.id, "tag");
const postBylines = p.data.bylines ?? [];
return { post: p, tags: postTags, bylines: postBylines };
})
// 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", {
Expand Down
36 changes: 20 additions & 16 deletions blog-cloudflare/src/pages/posts/index.astro
Original file line number Diff line number Diff line change
@@ -1,27 +1,31 @@
---
import { getEmDashCollection, getEntryTerms } from "emdash";
import { getEmDashCollection, getTermsForEntries } from "emdash";
import Base from "../../layouts/Base.astro";
import { getReadingTime } from "../../utils/reading-time";

const { entries: posts, cacheHint } = await getEmDashCollection("posts");
// 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);

const sortedPosts = posts.toSorted((a, b) => {
const dateA = a.data.publishedAt?.getTime() ?? 0;
const dateB = b.data.publishedAt?.getTime() ?? 0;
return dateB - dateA;
});

// Fetch tags for each post (bylines are already hydrated by getEmDashCollection)
const postsWithTags = await Promise.all(
sortedPosts.map(async (post) => {
const tags = await getEntryTerms("posts", post.data.id, "tag");
const bylines = post.data.bylines ?? [];
return { post, tags, bylines };
})
// 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",
Expand All @@ -41,7 +45,7 @@ const formatDate = (date: Date) =>
</header>

{
sortedPosts.length === 0 ? (
posts.length === 0 ? (
<p class="empty">No posts yet.</p>
) : (
<div class="posts-list">
Expand Down
Loading
Loading