diff --git a/src/layouts/PostLayout.astro b/src/layouts/PostLayout.astro index 547381c..f6817d9 100644 --- a/src/layouts/PostLayout.astro +++ b/src/layouts/PostLayout.astro @@ -3,7 +3,7 @@ import { render } from 'astro:content'; import '../styles/global.css'; import SiteHeader from '../components/SiteHeader.astro'; import PromoBanner from '../components/PromoBanner.astro'; -import { formatDate, relativeTime, shareImage, type Post } from '../lib/posts'; +import { formatDate, relativeTime, readingMinutes, shareImage, type Post } from '../lib/posts'; interface Props { post: Post; @@ -19,6 +19,7 @@ const showBanner = category === 'git' || category === 'tech'; const isoDate = date.toISOString(); const prettyDate = formatDate(date); const rel = relativeTime(date); +const readMins = readingMinutes(entry.body ?? ''); // Markdown/MDX render through Astro's pipeline; raw-HTML posts inject verbatim. let Content: Awaited>['Content'] | undefined; @@ -159,6 +160,8 @@ if (!isHtml) { {rel} + + {readMins} min read
{isHtml ? : Content && } diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 301fabd..907cc31 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -105,6 +105,27 @@ export function relativeTime(date: Date, now: Date = new Date()): string { return 'today'; } +/** Rough reading time in whole minutes from a raw entry body (markdown, MDX, or + * HTML). Strips MDX import/export lines, fenced code, JSX/MDX `{…}` expression + * blocks (so component data arrays aren't counted as prose), and tags, then + * counts words at `wpm`. Always at least 1. */ +export function readingMinutes(body: string, wpm = 200): number { + let text = body ?? ''; + text = text.replace(/^\s*(import|export)\b.*$/gm, ''); + text = text.replace(/```[\s\S]*?```/g, ' '); + // Remove `{…}` expression containers by brace depth (handles nested arrays). + let out = ''; + let depth = 0; + for (const ch of text) { + if (ch === '{') depth++; + else if (ch === '}') { if (depth > 0) depth--; } + else if (depth === 0) out += ch; + } + out = out.replace(/<[^>]+>/g, ' '); + const words = out.match(/[\p{L}\p{N}'’-]+/gu)?.length ?? 0; + return Math.max(1, Math.round(words / wpm)); +} + /** Social share image: covers are authored as .svg, but cards need the .png twin. */ export function shareImage(image: string | undefined): string { const img = image ?? '/assets/images/title.png';