From a9422006c011bf312ddd780ea6d59939269be4c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 27 Apr 2026 20:23:45 +0000 Subject: [PATCH] chore: sync templates from emdash v0.8.0 --- blank/package.json | 2 +- blog-cloudflare/package.json | 4 +- .../src/pages/category/[slug].astro | 28 +++-- blog-cloudflare/src/pages/index.astro | 81 ++++++++------ blog-cloudflare/src/pages/posts/[slug].astro | 41 ++++--- blog-cloudflare/src/pages/posts/index.astro | 36 +++--- blog-cloudflare/src/pages/search.astro | 103 ++++++++++++------ blog-cloudflare/src/pages/tag/[slug].astro | 27 +++-- blog/package.json | 2 +- blog/src/pages/category/[slug].astro | 28 +++-- blog/src/pages/index.astro | 81 ++++++++------ blog/src/pages/posts/[slug].astro | 41 ++++--- blog/src/pages/posts/index.astro | 36 +++--- blog/src/pages/search.astro | 103 ++++++++++++------ blog/src/pages/tag/[slug].astro | 27 +++-- marketing-cloudflare/astro.config.mjs | 36 ++++++ marketing-cloudflare/package.json | 6 +- .../src/components/blocks/Features.astro | 29 ++--- marketing-cloudflare/src/icons/.gitkeep | 0 marketing-cloudflare/src/layouts/Base.astro | 4 - marketing-cloudflare/src/pages/contact.astro | 10 +- marketing-cloudflare/src/pages/index.astro | 4 +- marketing-cloudflare/src/pages/pricing.astro | 4 +- marketing/astro.config.mjs | 25 +++++ marketing/package.json | 4 +- .../src/components/blocks/Features.astro | 29 ++--- marketing/src/icons/.gitkeep | 0 marketing/src/layouts/Base.astro | 4 - marketing/src/pages/contact.astro | 10 +- marketing/src/pages/index.astro | 4 +- marketing/src/pages/pricing.astro | 4 +- portfolio-cloudflare/package.json | 4 +- portfolio-cloudflare/src/pages/about.astro | 4 +- portfolio-cloudflare/src/pages/index.astro | 22 ++-- .../src/pages/work/[slug].astro | 19 ++-- .../src/pages/work/index.astro | 55 +++++----- portfolio/package.json | 2 +- portfolio/src/pages/about.astro | 4 +- portfolio/src/pages/index.astro | 22 ++-- portfolio/src/pages/work/[slug].astro | 19 ++-- portfolio/src/pages/work/index.astro | 55 +++++----- starter-cloudflare/package.json | 4 +- .../src/pages/posts/index.astro | 18 +-- starter/package.json | 2 +- starter/src/pages/posts/index.astro | 18 +-- 45 files changed, 646 insertions(+), 415 deletions(-) create mode 100644 marketing-cloudflare/src/icons/.gitkeep create mode 100644 marketing/src/icons/.gitkeep diff --git a/blank/package.json b/blank/package.json index d01d956..fa55257 100644 --- a/blank/package.json +++ b/blank/package.json @@ -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" }, diff --git a/blog-cloudflare/package.json b/blog-cloudflare/package.json index bb37b0b..3dcd0b0 100644 --- a/blog-cloudflare/package.json +++ b/blog-cloudflare/package.json @@ -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" }, diff --git a/blog-cloudflare/src/pages/category/[slug].astro b/blog-cloudflare/src/pages/category/[slug].astro index e0a94ef..6920785 100644 --- a/blog-cloudflare/src/pages/category/[slug].astro +++ b/blog-cloudflare/src/pages/category/[slug].astro @@ -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"; @@ -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) ?? [], +})); --- diff --git a/blog-cloudflare/src/pages/index.astro b/blog-cloudflare/src/pages/index.astro index 22683ab..92ca86c 100644 --- a/blog-cloudflare/src/pages/index.astro +++ b/blog-cloudflare/src/pages/index.astro @@ -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) { diff --git a/blog-cloudflare/src/pages/posts/[slug].astro b/blog-cloudflare/src/pages/posts/[slug].astro index bcc4c3d..1549fd5 100644 --- a/blog-cloudflare/src/pages/posts/[slug].astro +++ b/blog-cloudflare/src/pages/posts/[slug].astro @@ -3,6 +3,7 @@ import { getEmDashEntry, getEmDashCollection, getEntryTerms, + getTermsForEntries, getSeoMeta, decodeSlug, getSiteSettings, @@ -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", { diff --git a/blog-cloudflare/src/pages/posts/index.astro b/blog-cloudflare/src/pages/posts/index.astro index 2635592..d58dce8 100644 --- a/blog-cloudflare/src/pages/posts/index.astro +++ b/blog-cloudflare/src/pages/posts/index.astro @@ -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", @@ -41,7 +45,7 @@ const formatDate = (date: Date) => { - sortedPosts.length === 0 ? ( + posts.length === 0 ? (

No posts yet.

) : (
diff --git a/blog-cloudflare/src/pages/search.astro b/blog-cloudflare/src/pages/search.astro index fd29606..98e8a4e 100644 --- a/blog-cloudflare/src/pages/search.astro +++ b/blog-cloudflare/src/pages/search.astro @@ -1,29 +1,18 @@ --- export const prerender = false; -import { getEmDashCollection } from "emdash"; +import { search } from "emdash"; import Base from "../layouts/Base.astro"; -import PostCard from "../components/PostCard.astro"; -import { getReadingTime, extractText } from "../utils/reading-time"; const query = Astro.url.searchParams.get("q")?.trim() || ""; -const { entries: allPosts } = await getEmDashCollection("posts"); - -// Simple search: match query against title, excerpt, and content -function matchesQuery(post: (typeof allPosts)[0], q: string): boolean { - if (!q) return false; - const lower = q.toLowerCase(); - const title = (post.data.title || "").toLowerCase(); - const excerpt = (post.data.excerpt || "").toLowerCase(); - // Extract plain text from portable text blocks (avoids matching on _type, _key, etc.) - const content = extractText(post.data.content).toLowerCase(); - return ( - title.includes(lower) || excerpt.includes(lower) || content.includes(lower) - ); -} - -const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : []; +// 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: [] }; --- matchesQuery(p, query)) : []; { results.length > 0 && ( -
- {results.map((post) => ( - +
    + {results.map((result) => ( +
  1. + +

    + {result.title ?? "Untitled"} +

    + {result.snippet && ( +

    + )} + +

  2. ))} -
+ ) } @@ -134,8 +128,55 @@ const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : []; } .search-results { + list-style: none; + padding: 0; + margin: 0; display: flex; flex-direction: column; - gap: var(--spacing-8); + } + + .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 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; } diff --git a/blog-cloudflare/src/pages/tag/[slug].astro b/blog-cloudflare/src/pages/tag/[slug].astro index 34f3cc7..262f238 100644 --- a/blog-cloudflare/src/pages/tag/[slug].astro +++ b/blog-cloudflare/src/pages/tag/[slug].astro @@ -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"; @@ -11,18 +16,24 @@ if (!term) { return Astro.redirect("/404"); } -const { entries: posts } = await getEmDashCollection("posts", { +const { entries: posts, cacheHint } = await getEmDashCollection("posts", { where: { tag: 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 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) ?? [], +})); --- { - 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) ?? [], +})); --- diff --git a/blog/src/pages/index.astro b/blog/src/pages/index.astro index 22683ab..92ca86c 100644 --- a/blog/src/pages/index.astro +++ b/blog/src/pages/index.astro @@ -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) { diff --git a/blog/src/pages/posts/[slug].astro b/blog/src/pages/posts/[slug].astro index bcc4c3d..1549fd5 100644 --- a/blog/src/pages/posts/[slug].astro +++ b/blog/src/pages/posts/[slug].astro @@ -3,6 +3,7 @@ import { getEmDashEntry, getEmDashCollection, getEntryTerms, + getTermsForEntries, getSeoMeta, decodeSlug, getSiteSettings, @@ -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", { diff --git a/blog/src/pages/posts/index.astro b/blog/src/pages/posts/index.astro index 2635592..d58dce8 100644 --- a/blog/src/pages/posts/index.astro +++ b/blog/src/pages/posts/index.astro @@ -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", @@ -41,7 +45,7 @@ const formatDate = (date: Date) => { - sortedPosts.length === 0 ? ( + posts.length === 0 ? (

No posts yet.

) : (
diff --git a/blog/src/pages/search.astro b/blog/src/pages/search.astro index fd29606..98e8a4e 100644 --- a/blog/src/pages/search.astro +++ b/blog/src/pages/search.astro @@ -1,29 +1,18 @@ --- export const prerender = false; -import { getEmDashCollection } from "emdash"; +import { search } from "emdash"; import Base from "../layouts/Base.astro"; -import PostCard from "../components/PostCard.astro"; -import { getReadingTime, extractText } from "../utils/reading-time"; const query = Astro.url.searchParams.get("q")?.trim() || ""; -const { entries: allPosts } = await getEmDashCollection("posts"); - -// Simple search: match query against title, excerpt, and content -function matchesQuery(post: (typeof allPosts)[0], q: string): boolean { - if (!q) return false; - const lower = q.toLowerCase(); - const title = (post.data.title || "").toLowerCase(); - const excerpt = (post.data.excerpt || "").toLowerCase(); - // Extract plain text from portable text blocks (avoids matching on _type, _key, etc.) - const content = extractText(post.data.content).toLowerCase(); - return ( - title.includes(lower) || excerpt.includes(lower) || content.includes(lower) - ); -} - -const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : []; +// 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: [] }; --- matchesQuery(p, query)) : []; { results.length > 0 && ( -
- {results.map((post) => ( - +
    + {results.map((result) => ( +
  1. + +

    + {result.title ?? "Untitled"} +

    + {result.snippet && ( +

    + )} + +

  2. ))} -
+ ) } @@ -134,8 +128,55 @@ const results = query ? allPosts.filter((p) => matchesQuery(p, query)) : []; } .search-results { + list-style: none; + padding: 0; + margin: 0; display: flex; flex-direction: column; - gap: var(--spacing-8); + } + + .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 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; } diff --git a/blog/src/pages/tag/[slug].astro b/blog/src/pages/tag/[slug].astro index 34f3cc7..262f238 100644 --- a/blog/src/pages/tag/[slug].astro +++ b/blog/src/pages/tag/[slug].astro @@ -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"; @@ -11,18 +16,24 @@ if (!term) { return Astro.redirect("/404"); } -const { entries: posts } = await getEmDashCollection("posts", { +const { entries: posts, cacheHint } = await getEmDashCollection("posts", { where: { tag: 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 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) ?? [], +})); --- = { - zap: "lightning", - shield: "shield-check", - users: "users-three", - chart: "chart-bar", - code: "code", - globe: "globe", - heart: "heart", - star: "star", - check: "check-circle", - lock: "lock", - clock: "clock", - cloud: "cloud", + zap: "ph:lightning", + shield: "ph:shield-check", + users: "ph:users-three", + chart: "ph:chart-bar", + code: "ph:code", + globe: "ph:globe", + heart: "ph:heart", + star: "ph:star", + check: "ph:check-circle", + lock: "ph:lock", + clock: "ph:clock", + cloud: "ph:cloud", }; --- @@ -44,7 +45,7 @@ const iconMap: Record = { {features.map((feature) => (
- +

{feature.title}

{feature.description}

diff --git a/marketing-cloudflare/src/icons/.gitkeep b/marketing-cloudflare/src/icons/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/marketing-cloudflare/src/layouts/Base.astro b/marketing-cloudflare/src/layouts/Base.astro index ce4c13f..948b973 100644 --- a/marketing-cloudflare/src/layouts/Base.astro +++ b/marketing-cloudflare/src/layouts/Base.astro @@ -46,10 +46,6 @@ const pageCtx = createPublicPageContext({ {siteFavicon && } -