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), +})); ---