From 8130653389bf709bed3af97c64c23051a5cef22b Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Mon, 11 May 2026 02:50:27 +0200 Subject: [PATCH 01/84] ft: Implement blog setup --- next.config.ts | 10 + package.json | 10 +- public/blog/covers/hello-gofasta.svg | 18 + scripts/generate-llms-txt.mjs | 7 + src/app/(docs)/docs/[[...mdxPath]]/page.tsx | 81 +- src/app/api/og/route.tsx | 4 +- src/app/robots.ts | 13 +- src/app/sitemap.ts | 87 +- src/components/atoms/blog-tag-pill.test.tsx | 25 + src/components/atoms/blog-tag-pill.tsx | 28 + .../atoms/reading-progress-bar.test.tsx | 132 ++ src/components/atoms/reading-progress-bar.tsx | 64 + .../molecules/blog-article-header.test.tsx | 89 + .../molecules/blog-article-header.tsx | 80 + .../molecules/blog-pagination.test.tsx | 94 + src/components/molecules/blog-pagination.tsx | 77 + .../molecules/blog-post-card.test.tsx | 82 + src/components/molecules/blog-post-card.tsx | 80 + .../molecules/blog-prev-next.test.tsx | 64 + src/components/molecules/blog-prev-next.tsx | 57 + .../molecules/blog-related-posts.test.tsx | 140 ++ .../molecules/blog-related-posts.tsx | 74 + .../molecules/share-buttons.test.tsx | 99 + src/components/molecules/share-buttons.tsx | 72 + .../organisms/cookie-banner.test.tsx | 52 +- src/components/organisms/cookie-banner.tsx | 7 + src/content/blog/hello-gofasta.mdx | 45 + src/lib/analytics.ts | 4 +- src/lib/blog-mdx-components.test.tsx | 76 + src/lib/blog-mdx-components.tsx | 87 + src/lib/blog.test.ts | 480 ++++ src/lib/blog.ts | 234 ++ src/lib/feed.test.ts | 211 ++ src/lib/feed.ts | 162 ++ src/lib/og-footer.test.ts | 39 + src/lib/og-footer.ts | 22 + src/lib/reading-time.test.ts | 29 + src/lib/reading-time.ts | 24 + src/lib/structured-data.test.ts | 194 ++ src/lib/structured-data.ts | 225 ++ yarn.lock | 1997 ++++++++++++++++- 41 files changed, 5247 insertions(+), 128 deletions(-) create mode 100644 public/blog/covers/hello-gofasta.svg create mode 100644 src/components/atoms/blog-tag-pill.test.tsx create mode 100644 src/components/atoms/blog-tag-pill.tsx create mode 100644 src/components/atoms/reading-progress-bar.test.tsx create mode 100644 src/components/atoms/reading-progress-bar.tsx create mode 100644 src/components/molecules/blog-article-header.test.tsx create mode 100644 src/components/molecules/blog-article-header.tsx create mode 100644 src/components/molecules/blog-pagination.test.tsx create mode 100644 src/components/molecules/blog-pagination.tsx create mode 100644 src/components/molecules/blog-post-card.test.tsx create mode 100644 src/components/molecules/blog-post-card.tsx create mode 100644 src/components/molecules/blog-prev-next.test.tsx create mode 100644 src/components/molecules/blog-prev-next.tsx create mode 100644 src/components/molecules/blog-related-posts.test.tsx create mode 100644 src/components/molecules/blog-related-posts.tsx create mode 100644 src/components/molecules/share-buttons.test.tsx create mode 100644 src/components/molecules/share-buttons.tsx create mode 100644 src/content/blog/hello-gofasta.mdx create mode 100644 src/lib/blog-mdx-components.test.tsx create mode 100644 src/lib/blog-mdx-components.tsx create mode 100644 src/lib/blog.test.ts create mode 100644 src/lib/blog.ts create mode 100644 src/lib/feed.test.ts create mode 100644 src/lib/feed.ts create mode 100644 src/lib/og-footer.test.ts create mode 100644 src/lib/og-footer.ts create mode 100644 src/lib/reading-time.test.ts create mode 100644 src/lib/reading-time.ts create mode 100644 src/lib/structured-data.test.ts create mode 100644 src/lib/structured-data.ts diff --git a/next.config.ts b/next.config.ts index ecfffba..60df22e 100644 --- a/next.config.ts +++ b/next.config.ts @@ -24,5 +24,15 @@ export default withNextra({ images: { formats: ["image/avif", "image/webp"], minimumCacheTTL: 60 * 60 * 24 * 30, + // SVG covers are allowed for blog posts. Authoring goes through + // Keystatic (an authenticated admin UI gated behind GitHub OAuth) + // OR via direct PRs we review — never via untrusted user upload. + // The strict CSP below sandboxes the rendered SVG so scripts and + // foreign-origin assets inside the file are blocked, eliminating + // the XSS vector that makes user-uploaded SVGs risky on hosts + // that mirror unmodified user input. + dangerouslyAllowSVG: true, + contentSecurityPolicy: + "default-src 'self'; script-src 'none'; sandbox;", }, }); diff --git a/package.json b/package.json index 5439e53..cbb55f8 100644 --- a/package.json +++ b/package.json @@ -15,14 +15,22 @@ "generate:llms": "node scripts/generate-llms-txt.mjs" }, "dependencies": { + "@giscus/react": "^3.1.0", + "@keystatic/core": "^0.5.50", + "@keystatic/next": "^5.0.4", + "@mdx-js/mdx": "^3.1.1", "clsx": "^2.1.1", "next": "16.2.2", + "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "nextra": "^4.6.1", "nextra-theme-docs": "^4.6.1", "react": "19.2.4", "react-dom": "19.2.4", - "tailwind-merge": "^3.5.0" + "reading-time": "^1.5.0", + "rehype-pretty-code": "^0.14.3", + "tailwind-merge": "^3.5.0", + "yaml": "^2.8.4" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/public/blog/covers/hello-gofasta.svg b/public/blog/covers/hello-gofasta.svg new file mode 100644 index 0000000..6ee37ac --- /dev/null +++ b/public/blog/covers/hello-gofasta.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + G + Gofasta Blog + + Hello, Gofasta! + Notes from the team building a Go backend toolkit. + GOFASTA.DEV/BLOG + diff --git a/scripts/generate-llms-txt.mjs b/scripts/generate-llms-txt.mjs index 5b25f71..27d8d2e 100644 --- a/scripts/generate-llms-txt.mjs +++ b/scripts/generate-llms-txt.mjs @@ -117,6 +117,13 @@ async function collect(dir, urlPrefix = '', sectionPath = '') { for (const entry of ordered) { const full = join(dir, entry.name); if (entry.isDirectory()) { + // The blog has its own dedicated discovery surfaces (RSS feed, + // JSON Feed, sitemap entries, and Pagefind index). Indexing it + // here would (a) emit incorrect `/docs/blog/...` URLs since this + // script hardcodes `DOCS_BASE`, and (b) duplicate content into + // llms.txt that's already discoverable. Skip the entire blog + // subtree. + if (sectionPath === '' && entry.name === 'blog') continue; const childPrefix = `${urlPrefix}/${entry.name}`; const childSection = sectionPath ? `${sectionPath}/${entry.name}` : entry.name; pages.push(...(await collect(full, childPrefix, childSection))); diff --git a/src/app/(docs)/docs/[[...mdxPath]]/page.tsx b/src/app/(docs)/docs/[[...mdxPath]]/page.tsx index 98ee33f..0a2f2a4 100644 --- a/src/app/(docs)/docs/[[...mdxPath]]/page.tsx +++ b/src/app/(docs)/docs/[[...mdxPath]]/page.tsx @@ -1,6 +1,7 @@ import { generateStaticParamsFor, importPage } from "nextra/pages"; import { useMDXComponents as getMDXComponents } from "../../../../../mdx-components"; import { getKeywordsForPath } from "@/lib/seo-keywords"; +import { buildTechArticleJsonLd } from "@/lib/structured-data"; export const generateStaticParams = generateStaticParamsFor("mdxPath"); @@ -57,81 +58,19 @@ const { wrapper: Wrapper } = getMDXComponents() as Record< React.ComponentType<{ toc: unknown; metadata: unknown; children: React.ReactNode }> >; +// buildStructuredData now lives in src/lib/structured-data.ts so the +// /blog routes can share the breadcrumb logic. This thin local wrapper +// just unpacks Nextra's metadata shape into the lib's typed input. function buildStructuredData( mdxPath: string[] | undefined, meta: Record, ) { - const segments = mdxPath ?? []; - const urlPath = `/docs${segments.length > 0 ? `/${segments.join("/")}` : ""}`; - const fullUrl = `https://gofasta.dev${urlPath}`; - const title = meta?.title ?? "Documentation"; - const description = - meta?.description ?? - "Gofasta documentation for the Go backend toolkit."; - - // Section name derived from the first path segment (e.g. "cli-reference" → "Cli Reference"), - // mirroring the OG-image `section` so JSON-LD and OG share a single source of truth. - const articleSection = segments[0] - ? segments[0] - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()) - : "Docs"; - - // Mirror the OG image URL on the schema so Google can use it as the - // article hero image in rich results. Same query-string contract as - // generateMetadata above. - const ogImageUrl = `https://gofasta.dev/api/og?title=${encodeURIComponent(title)}§ion=${encodeURIComponent(articleSection)}`; - - // Per-page keywords come from the same SEO map as the metadata side - // — keep them in sync so structured data and meta tags don't diverge. - const keywords = getKeywordsForPath(urlPath); - - const breadcrumbItems = [ - { name: "Home", url: "https://gofasta.dev" }, - { name: "Docs", url: "https://gofasta.dev/docs" }, - ...segments.map((seg, i) => ({ - name: seg - .replace(/-/g, " ") - .replace(/\b\w/g, (c) => c.toUpperCase()), - url: `https://gofasta.dev/docs/${segments.slice(0, i + 1).join("/")}`, - })), - ]; - - return { - "@context": "https://schema.org", - "@graph": [ - { - "@type": "BreadcrumbList", - // The breadcrumb's parent: Google groups sitelinks better when - // BreadcrumbList nests under a WebPage with the current URL. - mainEntity: { "@type": "WebPage", "@id": fullUrl }, - itemListElement: breadcrumbItems.map((item, i) => ({ - "@type": "ListItem", - position: i + 1, - name: item.name, - item: item.url, - })), - }, - { - "@type": "TechArticle", - headline: title, - description, - url: fullUrl, - inLanguage: "en", - articleSection, - keywords: keywords.length > 0 ? keywords.join(", ") : undefined, - image: ogImageUrl, - author: { "@type": "Organization", name: "Gofasta" }, - publisher: { - "@type": "Organization", - name: "Gofasta", - url: "https://gofasta.dev", - logo: "https://gofasta.dev/logo.png", - }, - mainEntityOfPage: fullUrl, - }, - ], - }; + return buildTechArticleJsonLd({ + segments: mdxPath ?? [], + title: meta?.title ?? "Documentation", + description: + meta?.description ?? "Gofasta documentation for the Go backend toolkit.", + }); } export default async function Page(props: { diff --git a/src/app/api/og/route.tsx b/src/app/api/og/route.tsx index 5e5932b..3909512 100644 --- a/src/app/api/og/route.tsx +++ b/src/app/api/og/route.tsx @@ -1,5 +1,6 @@ import { ImageResponse } from "next/og"; import type { NextRequest } from "next/server"; +import { buildOgFooterUrl } from "@/lib/og-footer"; export const runtime = "nodejs"; @@ -15,6 +16,7 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl; const title = searchParams.get("title") ?? "Documentation"; const section = searchParams.get("section") ?? "Docs"; + const footerUrl = buildOgFooterUrl(section); return new ImageResponse( ( @@ -88,7 +90,7 @@ export async function GET(request: NextRequest) { }} > - gofasta.dev/docs + {footerUrl} Go Backend Toolkit diff --git a/src/app/robots.ts b/src/app/robots.ts index 61b40a8..6aaee8c 100644 --- a/src/app/robots.ts +++ b/src/app/robots.ts @@ -3,17 +3,20 @@ import type { MetadataRoute } from "next"; // robots.txt for the Gofasta documentation site. // // Allow the full site so search engines can index every guide, CLI -// reference, and API reference page. The only paths we hide are -// internal Next.js bundles and any private API routes (the OG-image -// renderer at /api/og is fine for crawlers to skip — it's a derived -// asset). +// reference, API reference, and blog page. The paths we hide are: +// +// - `/api/` — Next.js API routes, internal-only. +// - `/_next/` — bundler output, not content. +// - `/keystatic/` — Keystatic admin UI; auth-gated but no value to +// search engines and we don't want it indexed. +// - `/api/keystatic/` — Keystatic's server route handlers; same reason. export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: "*", allow: "/", - disallow: ["/api/", "/_next/"], + disallow: ["/api/", "/_next/", "/keystatic/", "/api/keystatic/"], }, ], sitemap: "https://gofasta.dev/sitemap.xml", diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 12c669f..c440e10 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,14 +1,25 @@ import type { MetadataRoute } from "next"; -import { readdirSync, statSync } from "fs"; +import { existsSync, readdirSync, statSync } from "fs"; import { join } from "path"; const BASE_URL = "https://gofasta.dev"; -// getMdxPaths walks src/content/ and emits the URL path for every -// .mdx file. The mapping mirrors Nextra's content routing — -// `index.mdx` → its parent slug, every other file → its slug. -function getMdxPaths(dir: string, basePath: string = ""): string[] { +// getMdxPaths walks an MDX content directory and emits URL paths for +// every .mdx file under it. The mapping mirrors Nextra's content +// routing — `index.mdx` resolves to its parent slug, every other file +// resolves to its slug. +// +// `urlPrefix` is the URL-space root the directory maps to. For docs +// pass "/docs"; for the blog pass "/blog". Previously this prefix was +// hardcoded; making it a parameter is what lets the same walker +// power both surfaces from one source. +function getMdxPaths( + dir: string, + urlPrefix: string, + basePath: string = "", +): string[] { const paths: string[] = []; + if (!existsSync(dir)) return paths; const entries = readdirSync(dir); for (const entry of entries) { @@ -16,14 +27,15 @@ function getMdxPaths(dir: string, basePath: string = ""): string[] { const stat = statSync(fullPath); if (stat.isDirectory()) { - paths.push(...getMdxPaths(fullPath, `${basePath}/${entry}`)); + paths.push(...getMdxPaths(fullPath, urlPrefix, `${basePath}/${entry}`)); } else if (entry.endsWith(".mdx")) { const slug = entry.replace(/\.mdx$/, ""); - const urlPath = slug === "index" && basePath === "" - ? "/docs" - : slug === "index" - ? `/docs${basePath}` - : `/docs${basePath}/${slug}`; + const urlPath = + slug === "index" && basePath === "" + ? urlPrefix + : slug === "index" + ? `${urlPrefix}${basePath}` + : `${urlPrefix}${basePath}/${slug}`; paths.push(urlPath); } } @@ -32,9 +44,9 @@ function getMdxPaths(dir: string, basePath: string = ""): string[] { } // sitemap.xml — every page that should be discoverable by search -// engines. The home page is high priority; the docs root sits one -// notch below it; individual doc pages sit at 0.7. The HTML sitemap -// at /sitemap is a secondary discovery surface for humans. +// engines. The home page is high priority; the section roots (`/docs`, +// `/blog`) sit one notch below; individual pages sit at 0.7. The HTML +// sitemap at /sitemap is a secondary discovery surface for humans. export default function sitemap(): MetadataRoute.Sitemap { const staticPages: MetadataRoute.Sitemap = [ { @@ -51,8 +63,25 @@ export default function sitemap(): MetadataRoute.Sitemap { }, ]; - const contentDir = join(process.cwd(), "src", "content"); - const docPaths = getMdxPaths(contentDir); + const contentRoot = join(process.cwd(), "src", "content"); + + // Docs: every MDX file under src/content/ EXCEPT the blog subtree + // (the blog has its own URL prefix and is handled below). + const docPaths = readdirSync(contentRoot) + .filter( + (entry) => + entry !== "blog" && + (entry.endsWith(".mdx") || + statSync(join(contentRoot, entry)).isDirectory()), + ) + .flatMap((entry) => { + const fullPath = join(contentRoot, entry); + if (statSync(fullPath).isDirectory()) { + return getMdxPaths(fullPath, "/docs", `/${entry}`); + } + const slug = entry.replace(/\.mdx$/, ""); + return [slug === "index" ? "/docs" : `/docs/${slug}`]; + }); const docPages: MetadataRoute.Sitemap = docPaths.map((path) => ({ url: `${BASE_URL}${path}`, @@ -61,5 +90,29 @@ export default function sitemap(): MetadataRoute.Sitemap { priority: path === "/docs" ? 0.9 : 0.7, })); - return [...staticPages, ...docPages]; + // Blog: every MDX file under src/content/blog/ → /blog/. + // The blog index itself (/blog) is generated by the route handler + // at src/app/(home)/blog/page.tsx; include it explicitly here. + const blogDir = join(contentRoot, "blog"); + const blogPaths = getMdxPaths(blogDir, "/blog"); + + const blogPages: MetadataRoute.Sitemap = []; + if (blogPaths.length > 0) { + blogPages.push({ + url: `${BASE_URL}/blog`, + lastModified: new Date(), + changeFrequency: "daily", + priority: 0.9, + }); + for (const path of blogPaths) { + blogPages.push({ + url: `${BASE_URL}${path}`, + lastModified: new Date(), + changeFrequency: "weekly", + priority: 0.7, + }); + } + } + + return [...staticPages, ...docPages, ...blogPages]; } diff --git a/src/components/atoms/blog-tag-pill.test.tsx b/src/components/atoms/blog-tag-pill.test.tsx new file mode 100644 index 0000000..b62d16d --- /dev/null +++ b/src/components/atoms/blog-tag-pill.test.tsx @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BlogTagPill } from "./blog-tag-pill"; + +describe("BlogTagPill", () => { + it("renders the tag prefixed with # and links to the tag index", () => { + render(); + const link = screen.getByRole("link", { name: "#golang" }); + expect(link).toHaveAttribute("href", "/blog/tags/golang"); + }); + + it("supports an explicit href override", () => { + render(); + expect( + screen.getByRole("link", { name: "#golang" }), + ).toHaveAttribute("href", "/custom/path"); + }); + + it("merges an additional className into the rendered link", () => { + render(); + const link = screen.getByRole("link", { name: "#cli" }); + expect(link.className).toMatch(/extra-pill-class/); + expect(link.className).toMatch(/rounded-full/); + }); +}); diff --git a/src/components/atoms/blog-tag-pill.tsx b/src/components/atoms/blog-tag-pill.tsx new file mode 100644 index 0000000..d05e054 --- /dev/null +++ b/src/components/atoms/blog-tag-pill.tsx @@ -0,0 +1,28 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; + +// Single tag chip linking to that tag's index page. Pure server +// component — no client interactivity. Pill styling tracks the +// site's design tokens (Go cyan accent) and adapts to dark mode via +// the existing `dark:` Tailwind variants. + +export interface BlogTagPillProps { + tag: string; + href?: string; + className?: string; +} + +export function BlogTagPill({ tag, href, className }: BlogTagPillProps) { + const target = href ?? `/blog/tags/${tag}`; + return ( + + #{tag} + + ); +} diff --git a/src/components/atoms/reading-progress-bar.test.tsx b/src/components/atoms/reading-progress-bar.test.tsx new file mode 100644 index 0000000..beb583c --- /dev/null +++ b/src/components/atoms/reading-progress-bar.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, act } from "@testing-library/react"; +import { ReadingProgressBar } from "./reading-progress-bar"; + +// jsdom doesn't ship requestAnimationFrame in a useful form for +// tests; stub it to invoke the callback synchronously so we can +// observe state transitions deterministically. +const originalRAF = global.requestAnimationFrame; +const originalCAF = global.cancelAnimationFrame; + +beforeEach(() => { + global.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof requestAnimationFrame; + global.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame; +}); + +afterEach(() => { + global.requestAnimationFrame = originalRAF; + global.cancelAnimationFrame = originalCAF; +}); + +function setScroll({ + scrollY, + scrollHeight, + clientHeight, +}: { + scrollY: number; + scrollHeight: number; + clientHeight: number; +}) { + Object.defineProperty(window, "scrollY", { + value: scrollY, + configurable: true, + writable: true, + }); + Object.defineProperty(document.documentElement, "scrollHeight", { + value: scrollHeight, + configurable: true, + }); + Object.defineProperty(document.documentElement, "clientHeight", { + value: clientHeight, + configurable: true, + }); +} + +describe("ReadingProgressBar", () => { + it("renders as a progressbar with reading-progress label", () => { + setScroll({ scrollY: 0, scrollHeight: 1000, clientHeight: 500 }); + render(); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toBeInTheDocument(); + }); + + it("starts at 0% when the page has not been scrolled", () => { + setScroll({ scrollY: 0, scrollHeight: 1000, clientHeight: 500 }); + render(); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "0"); + }); + + it("computes ratio from scrollY / (scrollHeight - clientHeight)", () => { + setScroll({ scrollY: 250, scrollHeight: 1000, clientHeight: 500 }); + render(); + // scrollable distance = 500; scrolled = 250 → 50% + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "50"); + }); + + it("reports 0 when the document is not scrollable (scrollHeight <= clientHeight)", () => { + setScroll({ scrollY: 0, scrollHeight: 400, clientHeight: 500 }); + render(); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "0"); + }); + + it("clamps negative scroll (overscroll bounce) to 0", () => { + setScroll({ scrollY: -50, scrollHeight: 1000, clientHeight: 500 }); + render(); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "0"); + }); + + it("clamps scroll beyond the end (overscroll past bottom) to 100", () => { + setScroll({ scrollY: 9999, scrollHeight: 1000, clientHeight: 500 }); + render(); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "100"); + }); + + it("updates progress when the window emits a scroll event", () => { + setScroll({ scrollY: 0, scrollHeight: 1000, clientHeight: 500 }); + render(); + act(() => { + setScroll({ scrollY: 500, scrollHeight: 1000, clientHeight: 500 }); + window.dispatchEvent(new Event("scroll")); + }); + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "100"); + }); + + it("updates progress when the window emits a resize event", () => { + setScroll({ scrollY: 250, scrollHeight: 1000, clientHeight: 500 }); + render(); + act(() => { + setScroll({ scrollY: 250, scrollHeight: 2000, clientHeight: 500 }); + window.dispatchEvent(new Event("resize")); + }); + // scrollable distance = 1500; scrolled = 250 → 17% (rounded) + expect( + screen.getByRole("progressbar", { name: "Reading progress" }), + ).toHaveAttribute("aria-valuenow", "17"); + }); + + it("removes its event listeners on unmount", () => { + setScroll({ scrollY: 0, scrollHeight: 1000, clientHeight: 500 }); + const removeSpy = vi.spyOn(window, "removeEventListener"); + const { unmount } = render(); + unmount(); + expect(removeSpy).toHaveBeenCalledWith("scroll", expect.any(Function)); + expect(removeSpy).toHaveBeenCalledWith("resize", expect.any(Function)); + removeSpy.mockRestore(); + }); +}); diff --git a/src/components/atoms/reading-progress-bar.tsx b/src/components/atoms/reading-progress-bar.tsx new file mode 100644 index 0000000..c58d330 --- /dev/null +++ b/src/components/atoms/reading-progress-bar.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useEffect, useState } from "react"; + +// 4px-tall progress bar pinned to the top of the viewport. Width +// scales from 0 → 100% as the reader scrolls from the first byte of +// the page to the last. Useful UX cue on long-form posts where the +// vertical scrollbar alone doesn't convey "how much is left." +// +// Why useEffect: this is one of the three legitimate cases per the +// website CLAUDE.md — DOM/event subscription to `window.scroll` that +// has no declarative equivalent. The handler is rAF-throttled so it +// doesn't pile up frames during fast scrolls. + +function computeProgress(): number { + const scrolled = window.scrollY; + const max = + document.documentElement.scrollHeight - document.documentElement.clientHeight; + if (max <= 0) return 0; + const ratio = scrolled / max; + if (ratio < 0) return 0; + if (ratio > 1) return 1; + return ratio; +} + +export function ReadingProgressBar() { + const [progress, setProgress] = useState(0); + + useEffect(() => { + let rafId = 0; + const handler = () => { + cancelAnimationFrame(rafId); + rafId = requestAnimationFrame(() => { + setProgress(computeProgress()); + }); + }; + // Set the initial value so the bar shows the correct width on + // first paint instead of always starting at 0. + setProgress(computeProgress()); + window.addEventListener("scroll", handler, { passive: true }); + window.addEventListener("resize", handler); + return () => { + cancelAnimationFrame(rafId); + window.removeEventListener("scroll", handler); + window.removeEventListener("resize", handler); + }; + }, []); + + return ( +
+
+
+ ); +} diff --git a/src/components/molecules/blog-article-header.test.tsx b/src/components/molecules/blog-article-header.test.tsx new file mode 100644 index 0000000..6296aae --- /dev/null +++ b/src/components/molecules/blog-article-header.test.tsx @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BlogArticleHeader } from "./blog-article-header"; +import type { BlogPost } from "@/lib/blog"; + +function post(overrides: Partial = {}): BlogPost { + return { + slug: "hello", + title: "Hello, World", + description: "An intro post.", + publishedAt: "2026-05-01T10:00:00.000Z", + author: "Test Author", + tags: ["intro", "meta"], + cover: "hello.svg", + coverUrl: "/blog/covers/hello.svg", + body: "# Hello", + readingTime: { text: "3 min read", minutes: 3, words: 600 }, + ...overrides, + }; +} + +describe("BlogArticleHeader", () => { + it("renders the title, description, and reading time", () => { + render(); + expect( + screen.getByRole("heading", { level: 1, name: "Hello, World" }), + ).toBeInTheDocument(); + expect(screen.getByText("An intro post.")).toBeInTheDocument(); + expect(screen.getByText("3 min read")).toBeInTheDocument(); + }); + + it("renders the author as plain text when authorUrl is missing", () => { + render(); + expect( + screen.queryByRole("link", { name: "Test Author" }), + ).not.toBeInTheDocument(); + expect(screen.getByText("Test Author")).toBeInTheDocument(); + }); + + it("renders the author as an external link when authorUrl is set", () => { + render( + , + ); + const link = screen.getByRole("link", { name: "Test Author" }); + expect(link).toHaveAttribute("href", "https://author.example"); + expect(link).toHaveAttribute("target", "_blank"); + expect(link).toHaveAttribute("rel", "noopener noreferrer"); + }); + + it("renders one tag pill per tag", () => { + render(); + expect(screen.getByRole("link", { name: "#a" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "#b" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "#c" })).toBeInTheDocument(); + }); + + it("omits the tag row entirely when the post has no tags", () => { + const { container } = render( + , + ); + expect(container.querySelectorAll('a[href^="/blog/tags/"]')).toHaveLength(0); + }); + + it("renders an 'Updated' timestamp only when updatedAt is set", () => { + const { rerender } = render(); + expect(screen.queryByText(/Updated/)).not.toBeInTheDocument(); + rerender( + , + ); + expect(screen.getByText(/Updated/)).toBeInTheDocument(); + expect(screen.getByText("June 1, 2026")).toBeInTheDocument(); + }); + + it("formats the publish date in long form", () => { + render(); + expect(screen.getByText("May 1, 2026")).toBeInTheDocument(); + }); + + it("renders the cover image with empty alt (decorative)", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img).toHaveAttribute("src", "/blog/covers/hello.svg"); + expect(img).toHaveAttribute("alt", ""); + }); +}); diff --git a/src/components/molecules/blog-article-header.tsx b/src/components/molecules/blog-article-header.tsx new file mode 100644 index 0000000..122ebf0 --- /dev/null +++ b/src/components/molecules/blog-article-header.tsx @@ -0,0 +1,80 @@ +import Image from "next/image"; +import Link from "next/link"; +import { BlogTagPill } from "@/components/atoms/blog-tag-pill"; +import type { BlogPost } from "@/lib/blog"; + +// Top-of-article header: tag pills, title, byline (author + date + +// reading time), and cover image. Renders on the post detail route +// and stays in document order so a print stylesheet or RSS preview +// gets the same surface a reader sees. + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric", + }); +} + +export interface BlogArticleHeaderProps { + post: BlogPost; +} + +export function BlogArticleHeader({ post }: BlogArticleHeaderProps) { + return ( +
+ {post.tags.length > 0 ? ( +
+ {post.tags.map((tag) => ( + + ))} +
+ ) : null} + +

+ {post.title} +

+ +

{post.description}

+ +
+ {post.authorUrl ? ( + + {post.author} + + ) : ( + {post.author} + )} + · + + · + {post.readingTime.text} + {post.updatedAt ? ( + <> + · + + Updated{" "} + + + + ) : null} +
+ +
+ +
+
+ ); +} diff --git a/src/components/molecules/blog-pagination.test.tsx b/src/components/molecules/blog-pagination.test.tsx new file mode 100644 index 0000000..4e81a77 --- /dev/null +++ b/src/components/molecules/blog-pagination.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BlogPagination } from "./blog-pagination"; + +describe("BlogPagination", () => { + it("renders nothing when there is only one page", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders one numbered link per page", () => { + render(); + expect(screen.getByRole("link", { name: "1" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "2" })).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "3" })).toBeInTheDocument(); + }); + + it("marks the current page with aria-current=page", () => { + render(); + expect(screen.getByRole("link", { name: "2" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "1" })).not.toHaveAttribute( + "aria-current", + ); + }); + + it("links page 1 to the base path without a ?page param", () => { + render(); + expect(screen.getByRole("link", { name: "1" })).toHaveAttribute( + "href", + "/blog", + ); + }); + + it("links pages > 1 to the base path with ?page=N", () => { + render(); + expect(screen.getByRole("link", { name: "2" })).toHaveAttribute( + "href", + "/blog?page=2", + ); + expect(screen.getByRole("link", { name: "3" })).toHaveAttribute( + "href", + "/blog?page=3", + ); + }); + + it("omits Previous on page 1 and renders Next", () => { + render(); + expect(screen.queryByRole("link", { name: "Previous" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Next" })).toHaveAttribute( + "href", + "/blog?page=2", + ); + }); + + it("omits Next on the last page and renders Previous", () => { + render(); + expect(screen.queryByRole("link", { name: "Next" })).not.toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Previous" })).toHaveAttribute( + "href", + "/blog?page=2", + ); + }); + + it("renders both Previous and Next on a middle page", () => { + render(); + expect(screen.getByRole("link", { name: "Previous" })).toHaveAttribute( + "href", + "/blog", + ); + expect(screen.getByRole("link", { name: "Next" })).toHaveAttribute( + "href", + "/blog?page=3", + ); + }); + + it("respects a custom basePath", () => { + render( + , + ); + expect(screen.getByRole("link", { name: "2" })).toHaveAttribute( + "href", + "/blog/tags/go?page=2", + ); + }); +}); diff --git a/src/components/molecules/blog-pagination.tsx b/src/components/molecules/blog-pagination.tsx new file mode 100644 index 0000000..1e8d8b7 --- /dev/null +++ b/src/components/molecules/blog-pagination.tsx @@ -0,0 +1,77 @@ +import Link from "next/link"; +import { cn } from "@/lib/utils"; + +// Numeric pagination for the blog index. Page 1 lives at `/blog`, +// every subsequent page lives at `/blog?page=N`. We render page +// numbers plus prev/next arrows. No ellipsis collapsing for now — +// at 12 posts/page the toolkit would need 100+ posts before any +// reasonable layout overflowed, which is years away. + +export interface BlogPaginationProps { + currentPage: number; + totalPages: number; + basePath?: string; +} + +function pageHref(base: string, page: number): string { + if (page <= 1) return base; + return `${base}?page=${page}`; +} + +export function BlogPagination({ + currentPage, + totalPages, + basePath = "/blog", +}: BlogPaginationProps) { + if (totalPages <= 1) return null; + + const pages = Array.from({ length: totalPages }, (_, i) => i + 1); + const hasPrev = currentPage > 1; + const hasNext = currentPage < totalPages; + + return ( + + ); +} diff --git a/src/components/molecules/blog-post-card.test.tsx b/src/components/molecules/blog-post-card.test.tsx new file mode 100644 index 0000000..7073f90 --- /dev/null +++ b/src/components/molecules/blog-post-card.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { BlogPostCard } from "./blog-post-card"; +import type { BlogPost } from "@/lib/blog"; + +function post(overrides: Partial = {}): BlogPost { + return { + slug: "hello", + title: "Hello, World", + description: "An intro post.", + publishedAt: "2026-05-01T10:00:00.000Z", + author: "Test Author", + tags: ["intro", "meta"], + cover: "hello.svg", + coverUrl: "/blog/covers/hello.svg", + body: "# Hello", + readingTime: { text: "1 min read", minutes: 0.5, words: 4 }, + ...overrides, + }; +} + +describe("BlogPostCard", () => { + it("links the whole card to the post and labels the link with the title", () => { + render(); + const link = screen.getByRole("link", { name: "Hello, World" }); + expect(link).toHaveAttribute("href", "/blog/hello"); + }); + + it("renders the cover image with the post's cover URL and empty alt", () => { + render(); + // Cover is decorative — empty alt removes it from a11y tree. + const card = screen.getByRole("link", { name: "Hello, World" }); + const img = card.querySelector("img"); + expect(img).toHaveAttribute("src", "/blog/covers/hello.svg"); + expect(img).toHaveAttribute("alt", ""); + }); + + it("renders a formatted publish date in a