From 7e3299e3bd23c49cd4d5d55250a8be12def66ba1 Mon Sep 17 00:00:00 2001 From: Latte Date: Sat, 7 Mar 2026 17:46:15 +0100 Subject: [PATCH] Add blog utilities and tag pages Introduce formatBlogDate, slugifyTag and getTagHref in src/lib/blog.ts and a reading time helper getReadingTime in src/lib/readingTime.ts. Update blog index and post pages to display ISO dates, reading times and link tags to /blog/tag/. Add a tag listing page at src/pages/blog/tag/[tag].astro with styles and static paths generation --- src/lib/blog.ts | 17 ++ src/lib/readingTime.ts | 19 ++ src/pages/blog/[...slug].astro | 90 +++++++- src/pages/blog/index.astro | 56 ++++- src/pages/blog/tag/[tag].astro | 395 +++++++++++++++++++++++++++++++++ 5 files changed, 554 insertions(+), 23 deletions(-) create mode 100644 src/lib/blog.ts create mode 100644 src/lib/readingTime.ts create mode 100644 src/pages/blog/tag/[tag].astro diff --git a/src/lib/blog.ts b/src/lib/blog.ts new file mode 100644 index 0000000..169f8df --- /dev/null +++ b/src/lib/blog.ts @@ -0,0 +1,17 @@ +export function formatBlogDate(date: Date) { + return date.toISOString().split("T")[0]; +} + +export function slugifyTag(tag: string) { + return tag + .toLowerCase() + .trim() + .replace(/&/g, " and ") + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +export function getTagHref(tag: string) { + return `/blog/tag/${slugifyTag(tag)}`; +} diff --git a/src/lib/readingTime.ts b/src/lib/readingTime.ts new file mode 100644 index 0000000..0ae82af --- /dev/null +++ b/src/lib/readingTime.ts @@ -0,0 +1,19 @@ +export function getReadingTime(content: string) { + const normalized = content + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]*`/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/[#>*_~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const wordCount = normalized ? normalized.split(" ").length : 0; + const minutes = Math.max(1, Math.ceil(wordCount / 200)); + + return { + minutes, + text: `${minutes} min read`, + }; +} diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 016dcd6..0ee1501 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -1,6 +1,8 @@ --- import BaseLayout from "../../layouts/BaseLayout.astro"; import { getCollection, type CollectionEntry } from "astro:content"; +import { formatBlogDate, getTagHref } from "../../lib/blog"; +import { getReadingTime } from "../../lib/readingTime"; type BlogPost = CollectionEntry<"blog">; @@ -69,10 +71,7 @@ const { }; const { Content } = await post.render(); - -function formatDate(date: Date) { - return date.toISOString().split("T")[0]; -} +const readingTime = getReadingTime(post.body); --- {post.data.title} -

{formatDate(post.data.pubDate)}

+
+ + + {readingTime.text} +
{ post.data.tags.length > 0 && (
{post.data.tags.map((tag) => ( - {tag} + + {tag} + ))}
) @@ -185,11 +195,26 @@ function formatDate(date: Date) { href={`/blog/${relatedPost.slug}`} class="related-card" > - - {formatDate( - relatedPost.data.pubDate, - )} - +

{relatedPost.data.title}

{relatedPost.data.description}

@@ -283,6 +308,22 @@ function formatDate(date: Date) { font-size: 0.85rem; } + .meta-row { + display: flex; + align-items: center; + gap: var(--space-xs); + color: var(--color-text-dim); + font-size: 0.85rem; + } + + .meta-sep { + color: var(--color-surface); + } + + .reading-time { + color: var(--color-accent); + } + .tag-list { display: flex; flex-wrap: wrap; @@ -296,6 +337,12 @@ function formatDate(date: Date) { border: 1px solid var(--color-surface); padding: 1px 6px; border-radius: 999px; + text-decoration: none; + } + + .tag:hover { + color: var(--color-accent-bright); + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); } .content { @@ -547,12 +594,26 @@ function formatDate(date: Date) { } .related-date { - display: inline-block; color: var(--color-text-dim); font-size: 0.8rem; + } + + .related-meta { + display: flex; + align-items: center; + gap: var(--space-xs); margin-bottom: var(--space-xs); } + .related-sep { + color: var(--color-surface); + } + + .related-reading-time { + color: var(--color-accent); + font-size: 0.78rem; + } + .related-card h3 { font-size: 1rem; color: var(--color-accent-bright); @@ -625,6 +686,11 @@ function formatDate(date: Date) { flex-direction: column; align-items: flex-start; } + + .meta-row, + .related-meta { + flex-wrap: wrap; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/pages/blog/index.astro b/src/pages/blog/index.astro index 1f1b89a..ab32b85 100644 --- a/src/pages/blog/index.astro +++ b/src/pages/blog/index.astro @@ -1,14 +1,17 @@ --- import BaseLayout from "../../layouts/BaseLayout.astro"; import { getCollection } from "astro:content"; +import { formatBlogDate, getTagHref } from "../../lib/blog"; +import { getReadingTime } from "../../lib/readingTime"; const posts = (await getCollection("blog", ({ data }) => !data.draft)).sort( (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), ); -function formatDate(date: Date) { - return date.toISOString().split("T")[0]; -} +const previewPosts = posts.map((post) => ({ + ...post, + readingTime: getReadingTime(post.body), +})); ---
    - {posts.map((post) => ( + {previewPosts.map((post) => (
  • - + )} @@ -156,12 +172,24 @@ function formatDate(date: Date) { align-items: flex-start; } + .post-meta-column { + min-width: 110px; + display: flex; + flex-direction: column; + gap: 2px; + } + .post-date { color: var(--color-text-dim); font-size: 0.8rem; white-space: nowrap; padding-top: 2px; - min-width: 90px; + } + + .post-reading-time { + color: var(--color-accent); + font-size: 0.72rem; + white-space: nowrap; } .post-info { @@ -198,6 +226,12 @@ function formatDate(date: Date) { border: 1px solid var(--color-surface); padding: 1px 6px; border-radius: 999px; + text-decoration: none; + } + + .tag:hover { + color: var(--color-accent-bright); + border-color: color-mix(in srgb, var(--color-accent) 45%, transparent); } .empty { @@ -259,7 +293,7 @@ function formatDate(date: Date) { gap: var(--space-xs); } - .post-date { + .post-meta-column { min-width: unset; } } diff --git a/src/pages/blog/tag/[tag].astro b/src/pages/blog/tag/[tag].astro new file mode 100644 index 0000000..fea83da --- /dev/null +++ b/src/pages/blog/tag/[tag].astro @@ -0,0 +1,395 @@ +--- +import BaseLayout from "../../../layouts/BaseLayout.astro"; +import { getCollection, type CollectionEntry } from "astro:content"; +import { formatBlogDate, getTagHref, slugifyTag } from "../../../lib/blog"; +import { getReadingTime } from "../../../lib/readingTime"; + +type BlogPost = CollectionEntry<"blog">; + +export async function getStaticPaths() { + const posts = await getCollection("blog", ({ data }) => !data.draft); + const tagMap = new Map(); + + for (const post of posts) { + for (const tag of post.data.tags) { + const slug = slugifyTag(tag); + const existing = tagMap.get(slug); + + if (existing) { + existing.posts.push(post); + } else { + tagMap.set(slug, { tag, posts: [post] }); + } + } + } + + return [...tagMap.entries()].map(([slug, entry]) => ({ + params: { tag: slug }, + props: { + tag: entry.tag, + posts: entry.posts.sort( + (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(), + ), + }, + })); +} + +const { tag, posts } = Astro.props as { + tag: string; + posts: BlogPost[]; +}; + +const previewPosts = posts.map((post) => ({ + ...post, + readingTime: getReadingTime(post.body), +})); +--- + + + + -- 2.52.0