diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
new file mode 100644
index 0000000..fd41df6
--- /dev/null
+++ b/.claude/CLAUDE.md
@@ -0,0 +1,44 @@
+# michaeluloth.com - Claude Code Rules
+
+## TypeScript Type Safety
+
+### Never use `any`
+
+**Rule:** Never use `any`, `as any`, or `@typescript-eslint/no-explicit-any` to bypass type errors.
+
+**Why:** Type errors indicate a mismatch between what TypeScript expects and what you're providing. The solution is always to communicate the real type properly, not to suppress the error.
+
+**How to fix type errors properly:**
+
+1. **Understand what TypeScript expects** - Read the error message carefully to understand the expected type
+2. **Provide the correct type** - Use proper type annotations, interfaces, or type assertions with the actual type
+3. **Use the right syntax** - If a library expects JSX/ReactNode, use JSX syntax (`.tsx` files) instead of plain objects
+4. **Import necessary types** - Ensure you have the correct imports (e.g., `React` for JSX)
+
+**Example: Satori type error**
+
+```typescript
+// ❌ WRONG - bypassing the type error
+const svg = await satori(
+ {
+ type: 'div',
+ props: { /* ... */ }
+ } as any, // Never do this!
+ options
+)
+
+// ✅ CORRECT - using proper JSX syntax that TypeScript understands
+// 1. Rename file from .ts to .tsx
+// 2. Import React
+import React from 'react'
+
+// 3. Use JSX syntax instead of plain objects
+const svg = await satori(
+
+ {/* ... */}
+
,
+ options
+)
+```
+
+This rule ensures the codebase maintains full type safety and prevents runtime errors that `any` would hide.
diff --git a/.gitignore b/.gitignore
index b41a7c8..3338992 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,20 +1,19 @@
# Theirs
-node_modules/
.netlify/
.next/
.playwright-mcp/
.vercel/
build/
+node_modules/
out/
.DS_Store
*.log
*.tsbuildinfo
next-env.d.ts
-.lighthouseci/
-ci/lighthouse/urls.json
# Mine
-**/cloudinary/*.json
.env
.env.local
+.lighthouseci/
.local-cache/
+ci/lighthouse/urls.json
diff --git a/app/(prose)/page.test.tsx b/app/(home)/page.test.tsx
similarity index 89%
rename from app/(prose)/page.test.tsx
rename to app/(home)/page.test.tsx
index bf1861c..2428ff1 100644
--- a/app/(prose)/page.test.tsx
+++ b/app/(home)/page.test.tsx
@@ -11,14 +11,14 @@ import { Ok } from '@/utils/errors/result'
// Mock dependencies
vi.mock('@/io/notion/getPosts')
-vi.mock('@/ui/image', () => ({
+vi.mock('@/ui/elements/image', () => ({
// eslint-disable-next-line @next/next/no-img-element -- Using img in test mock is acceptable
default: ({ url }: { url: string }) => ,
}))
// Mock PostList to avoid async server component complexity in tests
-// The actual PostList behavior is tested in ui/post-list.test.tsx
-vi.mock('@/ui/post-list', () => ({
+// The actual PostList behavior is tested in ui/sections/blog-post-list.test.tsx
+vi.mock('@/ui/sections/blog-post-list', () => ({
default: ({ limit }: { limit?: number }) => {
// Call getPosts to verify it's called with correct params
// This ensures the mock is called during render which we verify in tests
@@ -29,6 +29,15 @@ vi.mock('@/ui/post-list', () => ({
},
}))
+// Mock PageLayout to avoid Next.js usePathname() in Header component
+vi.mock('@/ui/layout/page-layout', () => ({
+ default: ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ ),
+}))
+
describe('Home page', () => {
beforeEach(() => {
vi.clearAllMocks()
diff --git a/app/(home)/page.tsx b/app/(home)/page.tsx
new file mode 100644
index 0000000..635fd8a
--- /dev/null
+++ b/app/(home)/page.tsx
@@ -0,0 +1,15 @@
+import { type ReactElement } from 'react'
+import PageLayout from '@/ui/layout/page-layout'
+import Summary from '@/ui/sections/home-summary'
+import RecentWriting from '@/ui/sections/home-recent-writing'
+import JsonLdScript from '@/seo/json-ld/script'
+
+export default async function Home(): Promise {
+ return (
+
+
+
+
+
+ )
+}
diff --git a/app/(prose)/[slug]/page.tsx b/app/(prose)/[slug]/page.tsx
deleted file mode 100644
index 090ce07..0000000
--- a/app/(prose)/[slug]/page.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { type Metadata } from 'next'
-import { notFound } from 'next/navigation'
-
-import getPost from '@/io/notion/getPost'
-import getPosts from '@/io/notion/getPosts'
-import { SITE_URL, SITE_NAME, SITE_AUTHOR, TWITTER_HANDLE, DEFAULT_OG_IMAGE } from '@/utils/metadata'
-import { transformCloudinaryForOG } from '@/io/cloudinary/ogImageTransforms'
-import type { Post as PostType } from '@/io/notion/schemas/post'
-
-import Post from './ui/post'
-
-type Params = {
- slug: string
-}
-
-type Props = Readonly<{
- params: Promise
-}>
-
-export default async function DynamicRoute({ params }: Props) {
- const slug = (await params).slug
-
- const post = (await getPost({ slug, includeBlocks: true, includePrevAndNext: true })).unwrap()
-
- if (!post) {
- notFound()
- }
-
- const jsonLd = generateJsonLd(post, slug)
-
- return (
- <>
- ',
+ description: 'Test with ',
+ firstPublished: '2024-01-15',
+ lastEditedTime: '2024-01-15T00:00:00.000Z',
+ featuredImage: null,
+ blocks: [],
+ prevPost: null,
+ nextPost: null,
+ }
+
+ const { container } = render()
+
+ const script = container.querySelector('script')
+ const content = script?.innerHTML
+ // The < should be escaped as \u003c
+ expect(content).toContain('\\u003c/script>')
+ expect(content).toContain('\\u003ctags>')
+ // Should NOT contain unescaped
+ expect(content).not.toContain('
+ *
+ * @example
+ *
+ *
+ *
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html
+ */
+export default function JsonLdScript(props: JsonLdScriptProps) {
+ let data: Record
+
+ switch (props.type) {
+ case 'person':
+ data = generatePersonJsonLd()
+ break
+ case 'blog':
+ data = generateBlogJsonLd()
+ break
+ case 'article':
+ data = generateArticleJsonLd(props.post)
+ break
+ }
+
+ return (
+
+ )
+}
diff --git a/seo/pages/base.ts b/seo/pages/base.ts
new file mode 100644
index 0000000..684deee
--- /dev/null
+++ b/seo/pages/base.ts
@@ -0,0 +1,39 @@
+import { type Metadata } from 'next'
+
+import {
+ SITE_URL,
+ SITE_NAME,
+ SITE_DESCRIPTION,
+ SITE_AUTHOR,
+ TWITTER_HANDLE,
+ TWITTER_CARD,
+ DEFAULT_OG_IMAGE,
+ SITE_LOCALE,
+} from '@/seo/constants'
+
+export const metadata: Metadata = {
+ metadataBase: new URL(SITE_URL),
+ title: {
+ default: SITE_NAME,
+ template: `%s • ${SITE_NAME}`,
+ },
+ description: SITE_DESCRIPTION,
+ authors: [{ name: SITE_AUTHOR }],
+ creator: SITE_AUTHOR,
+ openGraph: {
+ type: 'website',
+ url: SITE_URL,
+ locale: SITE_LOCALE,
+ siteName: SITE_NAME,
+ images: [DEFAULT_OG_IMAGE],
+ },
+ twitter: {
+ card: TWITTER_CARD,
+ creator: TWITTER_HANDLE,
+ },
+ alternates: {
+ types: {
+ 'application/rss+xml': '/rss.xml',
+ },
+ },
+}
diff --git a/seo/pages/blog.ts b/seo/pages/blog.ts
new file mode 100644
index 0000000..62886c8
--- /dev/null
+++ b/seo/pages/blog.ts
@@ -0,0 +1,24 @@
+import { type Metadata } from 'next'
+
+import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE, TWITTER_CARD, SITE_LOCALE } from '@/seo/constants'
+
+const description = 'Technical writing about web development, TypeScript, React, and software engineering.'
+
+export const metadata: Metadata = {
+ title: 'Blog',
+ description,
+ openGraph: {
+ type: 'website',
+ url: `${SITE_URL}blog/`,
+ siteName: SITE_NAME,
+ locale: SITE_LOCALE,
+ images: [DEFAULT_OG_IMAGE],
+ },
+ twitter: {
+ card: TWITTER_CARD,
+ creator: TWITTER_HANDLE,
+ title: 'Blog',
+ description,
+ images: [DEFAULT_OG_IMAGE],
+ },
+}
diff --git a/seo/pages/likes.ts b/seo/pages/likes.ts
new file mode 100644
index 0000000..289f4e9
--- /dev/null
+++ b/seo/pages/likes.ts
@@ -0,0 +1,24 @@
+import { type Metadata } from 'next'
+
+import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE, TWITTER_CARD, SITE_LOCALE } from '@/seo/constants'
+
+const description = 'My favorite TV shows, movies, books, albums, and podcasts'
+
+export const metadata: Metadata = {
+ title: 'Likes',
+ description,
+ openGraph: {
+ type: 'website',
+ url: `${SITE_URL}likes/`,
+ siteName: SITE_NAME,
+ locale: SITE_LOCALE,
+ images: [DEFAULT_OG_IMAGE],
+ },
+ twitter: {
+ card: TWITTER_CARD,
+ creator: TWITTER_HANDLE,
+ title: 'Likes',
+ description,
+ images: [DEFAULT_OG_IMAGE],
+ },
+}
diff --git a/seo/pages/post.ts b/seo/pages/post.ts
new file mode 100644
index 0000000..4912bce
--- /dev/null
+++ b/seo/pages/post.ts
@@ -0,0 +1,39 @@
+import { type Metadata } from 'next'
+
+import { transformCloudinaryForOG } from '@/io/cloudinary/ogImageTransforms'
+import { type Post } from '@/io/notion/schemas/post'
+import { SITE_NAME, SITE_AUTHOR, TWITTER_HANDLE, TWITTER_CARD, DEFAULT_OG_IMAGE, SITE_LOCALE } from '@/seo/constants'
+import { getPostUrl } from '@/seo/json-ld/article'
+
+export default function metadata(post: Post): Metadata {
+ const url = getPostUrl(post.slug)
+ const rawImage = post.featuredImage ?? DEFAULT_OG_IMAGE
+ const ogImage = transformCloudinaryForOG(rawImage)
+
+ return {
+ title: post.title,
+ description: post.description,
+ alternates: {
+ canonical: url,
+ },
+ openGraph: {
+ type: 'article',
+ url,
+ siteName: SITE_NAME,
+ locale: SITE_LOCALE,
+ title: post.title,
+ description: post.description,
+ publishedTime: post.firstPublished,
+ modifiedTime: post.lastEditedTime,
+ authors: [SITE_AUTHOR],
+ images: [ogImage],
+ },
+ twitter: {
+ card: TWITTER_CARD,
+ creator: TWITTER_HANDLE,
+ title: post.title,
+ description: post.description,
+ images: [ogImage],
+ },
+ }
+}
diff --git a/ui/card.tsx b/ui/elements/card.tsx
similarity index 100%
rename from ui/card.tsx
rename to ui/elements/card.tsx
diff --git a/ui/code.test.tsx b/ui/elements/code.test.tsx
similarity index 100%
rename from ui/code.test.tsx
rename to ui/elements/code.test.tsx
diff --git a/ui/code.tsx b/ui/elements/code.tsx
similarity index 100%
rename from ui/code.tsx
rename to ui/elements/code.tsx
diff --git a/ui/dot.test.tsx b/ui/elements/dot.test.tsx
similarity index 100%
rename from ui/dot.test.tsx
rename to ui/elements/dot.test.tsx
diff --git a/ui/dot.tsx b/ui/elements/dot.tsx
similarity index 100%
rename from ui/dot.tsx
rename to ui/elements/dot.tsx
diff --git a/ui/emoji.tsx b/ui/elements/emoji.tsx
similarity index 100%
rename from ui/emoji.tsx
rename to ui/elements/emoji.tsx
diff --git a/ui/typography/heading.test.tsx b/ui/elements/heading.test.tsx
similarity index 100%
rename from ui/typography/heading.test.tsx
rename to ui/elements/heading.test.tsx
diff --git a/ui/typography/heading.tsx b/ui/elements/heading.tsx
similarity index 100%
rename from ui/typography/heading.tsx
rename to ui/elements/heading.tsx
diff --git a/ui/icon.test.tsx b/ui/elements/icon.test.tsx
similarity index 100%
rename from ui/icon.test.tsx
rename to ui/elements/icon.test.tsx
diff --git a/ui/icon.tsx b/ui/elements/icon.tsx
similarity index 100%
rename from ui/icon.tsx
rename to ui/elements/icon.tsx
diff --git a/ui/image.test.tsx b/ui/elements/image.test.tsx
similarity index 100%
rename from ui/image.test.tsx
rename to ui/elements/image.test.tsx
diff --git a/ui/image.tsx b/ui/elements/image.tsx
similarity index 100%
rename from ui/image.tsx
rename to ui/elements/image.tsx
diff --git a/ui/link.test.tsx b/ui/elements/link.test.tsx
similarity index 100%
rename from ui/link.test.tsx
rename to ui/elements/link.test.tsx
diff --git a/ui/link.tsx b/ui/elements/link.tsx
similarity index 100%
rename from ui/link.tsx
rename to ui/elements/link.tsx
diff --git a/ui/list.tsx b/ui/elements/list.tsx
similarity index 100%
rename from ui/list.tsx
rename to ui/elements/list.tsx
diff --git a/ui/typography/paragraph.tsx b/ui/elements/paragraph.tsx
similarity index 100%
rename from ui/typography/paragraph.tsx
rename to ui/elements/paragraph.tsx
diff --git a/ui/video.test.tsx b/ui/elements/video.test.tsx
similarity index 100%
rename from ui/video.test.tsx
rename to ui/elements/video.test.tsx
diff --git a/ui/video.tsx b/ui/elements/video.tsx
similarity index 100%
rename from ui/video.tsx
rename to ui/elements/video.tsx
diff --git a/ui/footer.test.tsx b/ui/layout/footer.test.tsx
similarity index 100%
rename from ui/footer.test.tsx
rename to ui/layout/footer.test.tsx
diff --git a/ui/footer.tsx b/ui/layout/footer.tsx
similarity index 62%
rename from ui/footer.tsx
rename to ui/layout/footer.tsx
index ee21b5a..3ae1eb7 100644
--- a/ui/footer.tsx
+++ b/ui/layout/footer.tsx
@@ -4,16 +4,17 @@ import xIcon from '@/public/x.svg'
import linkedInIcon from '@/public/linkedin.svg'
import rssIcon from '@/public/rss.svg'
-import Icon from '@/ui/icon'
-import Link from '@/ui/link'
-import Paragraph from '@/ui/typography/paragraph'
+import Icon from '@/ui/elements/icon'
+import Link from '@/ui/elements/link'
+import Paragraph from '@/ui/elements/paragraph'
+import { SOCIAL_URLS } from '@/seo/constants'
const socials = [
- { label: 'RSS', icon: rssIcon, href: '/rss.xml' },
- { label: 'YouTube', icon: youTubeIcon, href: 'https://youtube.com/michaeluloth' },
- { label: 'GitHub', icon: gitHubIcon, href: 'https://github.com/ooloth' },
- { label: 'X (Twitter)', icon: xIcon, href: 'https://x.com/ooloth' },
- { label: 'LinkedIn', icon: linkedInIcon, href: 'https://www.linkedin.com/in/michaeluloth' },
+ { label: 'RSS', icon: rssIcon, href: SOCIAL_URLS.rss },
+ { label: 'YouTube', icon: youTubeIcon, href: SOCIAL_URLS.youtube },
+ { label: 'GitHub', icon: gitHubIcon, href: SOCIAL_URLS.github },
+ { label: 'X (Twitter)', icon: xIcon, href: SOCIAL_URLS.twitter },
+ { label: 'LinkedIn', icon: linkedInIcon, href: SOCIAL_URLS.linkedin },
] as const
function SocialNav() {
diff --git a/ui/nav/primary.test.tsx b/ui/layout/header-nav.test.tsx
similarity index 65%
rename from ui/nav/primary.test.tsx
rename to ui/layout/header-nav.test.tsx
index 138cd34..59ce534 100644
--- a/ui/nav/primary.test.tsx
+++ b/ui/layout/header-nav.test.tsx
@@ -4,9 +4,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
-import PrimaryNav from './primary'
+
import type { PostListItem } from '@/io/notion/schemas/post'
+import PrimaryNav from './header-nav'
+import { isCurrentPage } from './header-nav'
+import type { NavItem } from './header-nav'
+
// Mock next/navigation
vi.mock('next/navigation', () => ({
usePathname: vi.fn(),
@@ -193,3 +197,87 @@ describe('PrimaryNav', () => {
})
})
})
+
+describe('isCurrentPage', () => {
+ const mockPosts: PostListItem[] = [
+ {
+ id: '1',
+ slug: 'test-post',
+ title: 'Test Post',
+ description: 'Test description',
+ firstPublished: '2024-01-15',
+ featuredImage: null,
+ },
+ {
+ id: '2',
+ slug: 'another-post',
+ title: 'Another Post',
+ description: 'Test description',
+ firstPublished: '2024-01-14',
+ featuredImage: null,
+ },
+ ]
+
+ describe('exact path matches', () => {
+ it('returns true when navItem href exactly matches pathname', () => {
+ const navItem: NavItem = { text: 'Home', href: '/' }
+ expect(isCurrentPage(navItem, '/', mockPosts)).toBe(true)
+ })
+
+ it('returns true when navItem href matches pathname for /likes/', () => {
+ const navItem: NavItem = { text: 'Likes', href: '/likes/' }
+ expect(isCurrentPage(navItem, '/likes/', mockPosts)).toBe(true)
+ })
+
+ it('returns false when navItem href does not match pathname', () => {
+ const navItem: NavItem = { text: 'Home', href: '/' }
+ expect(isCurrentPage(navItem, '/blog/', mockPosts)).toBe(false)
+ })
+ })
+
+ describe('blog post detection', () => {
+ it('returns true when navItem is blog and pathname matches a post slug', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ expect(isCurrentPage(navItem, '/test-post/', mockPosts)).toBe(true)
+ })
+
+ it('returns true when navItem is blog and pathname matches another post slug', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ expect(isCurrentPage(navItem, '/another-post/', mockPosts)).toBe(true)
+ })
+
+ it('returns false when navItem is blog but pathname does not match any post slug', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ expect(isCurrentPage(navItem, '/unknown-page/', mockPosts)).toBe(false)
+ })
+
+ it('returns true when navItem is blog and pathname is exactly /blog/', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ expect(isCurrentPage(navItem, '/blog/', mockPosts)).toBe(true)
+ })
+
+ it('returns false when navItem is not blog and pathname matches a post slug', () => {
+ const navItem: NavItem = { text: 'Home', href: '/' }
+ expect(isCurrentPage(navItem, '/test-post/', mockPosts)).toBe(false)
+ })
+ })
+
+ describe('edge cases', () => {
+ it('returns false when posts array is empty and checking blog post match', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ expect(isCurrentPage(navItem, '/test-post/', [])).toBe(false)
+ })
+
+ it('handles pathnames without trailing slashes', () => {
+ const navItem: NavItem = { text: 'Blog', href: '/blog/' }
+ // This test assumes the actual pathname from Next.js includes the trailing slash
+ // If the implementation needs to handle both, this test documents current behavior
+ expect(isCurrentPage(navItem, '/test-post', mockPosts)).toBe(false)
+ })
+
+ it('returns false for empty pathname', () => {
+ const navItem: NavItem = { text: 'Home', href: '/' }
+ expect(isCurrentPage(navItem, '', mockPosts)).toBe(false)
+ })
+ })
+})
diff --git a/ui/nav/primary.tsx b/ui/layout/header-nav.tsx
similarity index 67%
rename from ui/nav/primary.tsx
rename to ui/layout/header-nav.tsx
index 943ccfe..3ca6481 100644
--- a/ui/nav/primary.tsx
+++ b/ui/layout/header-nav.tsx
@@ -3,11 +3,15 @@
import { usePathname } from 'next/navigation'
import { type ReactElement } from 'react'
-import Link from '@/ui/link'
-import { type NavItem } from '@/ui/nav/types'
-import { isCurrentPage } from '@/ui/nav/utils'
+import Link from '@/ui/elements/link'
import { type PostListItem } from '@/io/notion/schemas/post'
-import Emoji from '../emoji'
+import Emoji from '@/ui/elements/emoji'
+
+// Exported for testing purposes
+export type NavItem = {
+ text: string
+ href: string
+}
const nav: NavItem[] = [
{ text: 'Home', href: '/' },
@@ -18,6 +22,24 @@ const nav: NavItem[] = [
{ text: 'Likes', href: '/likes/' },
]
+/**
+ * Determines if the given navigation item corresponds to the current page.
+ * Exported for testing purposes.
+ */
+export function isCurrentPage(navItem: NavItem, pathname: string, posts: PostListItem[]): boolean {
+ // Exact match?
+ if (navItem.href === pathname) {
+ return true
+ }
+
+ // Blog post?
+ if (navItem.href === '/blog/') {
+ return posts.some(post => pathname === `/${post.slug}/`)
+ }
+
+ // No match
+ return false
+}
type Props = Readonly<{
posts: PostListItem[]
}>
diff --git a/ui/header.tsx b/ui/layout/header.tsx
similarity index 86%
rename from ui/header.tsx
rename to ui/layout/header.tsx
index 08d1cfb..65dc767 100644
--- a/ui/header.tsx
+++ b/ui/layout/header.tsx
@@ -1,5 +1,5 @@
import getPosts from '@/io/notion/getPosts'
-import PrimaryNav from '@/ui/nav/primary'
+import PrimaryNav from '@/ui/layout/header-nav'
export default async function Header() {
const posts = (await getPosts({ sortDirection: 'descending' })).unwrap()
diff --git a/ui/layout/page-layout.tsx b/ui/layout/page-layout.tsx
new file mode 100644
index 0000000..41bb1e7
--- /dev/null
+++ b/ui/layout/page-layout.tsx
@@ -0,0 +1,38 @@
+import { type ReactNode } from 'react'
+import Header from '@/ui/layout/header'
+import Footer from '@/ui/layout/footer'
+
+type PageLayoutProps = Readonly<{
+ children: ReactNode
+ /**
+ * Controls the max width of the page content.
+ * - 'prose': 45rem max width, centered (for reading content)
+ * - 'full': No max width constraint
+ */
+ width?: 'prose' | 'full'
+}>
+
+export default function PageLayout({ children, width = 'prose' }: PageLayoutProps) {
+ const widthClasses = width === 'prose' ? 'mx-auto max-w-[45rem] w-full' : ''
+
+ const skipLinkId = 'main'
+ const skipLinkClasses =
+ 'sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:px-4 focus:py-2 focus:bg-accent focus:text-black focus:rounded'
+
+ return (
+
+
+ Skip to main content
+
+
+
+
+ {/* Main content element is the skip-link target and grows to fill the available vertical space */}
+
+ {children}
+
+
+
+
+ )
+}
diff --git a/ui/nav/types.d.ts b/ui/nav/types.d.ts
deleted file mode 100644
index 36358c6..0000000
--- a/ui/nav/types.d.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export type NavItem = {
- text: string
- href: string
-}
diff --git a/ui/nav/utils.test.ts b/ui/nav/utils.test.ts
deleted file mode 100644
index ec7ad9c..0000000
--- a/ui/nav/utils.test.ts
+++ /dev/null
@@ -1,88 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { isCurrentPage } from './utils'
-import type { NavItem } from './types'
-import type { PostListItem } from '@/io/notion/schemas/post'
-
-describe('isCurrentPage', () => {
- const mockPosts: PostListItem[] = [
- {
- id: '1',
- slug: 'test-post',
- title: 'Test Post',
- description: 'Test description',
- firstPublished: '2024-01-15',
- featuredImage: null,
- },
- {
- id: '2',
- slug: 'another-post',
- title: 'Another Post',
- description: 'Test description',
- firstPublished: '2024-01-14',
- featuredImage: null,
- },
- ]
-
- describe('exact path matches', () => {
- it('returns true when navItem href exactly matches pathname', () => {
- const navItem: NavItem = { text: 'Home', href: '/' }
- expect(isCurrentPage(navItem, '/', mockPosts)).toBe(true)
- })
-
- it('returns true when navItem href matches pathname for /likes/', () => {
- const navItem: NavItem = { text: 'Likes', href: '/likes/' }
- expect(isCurrentPage(navItem, '/likes/', mockPosts)).toBe(true)
- })
-
- it('returns false when navItem href does not match pathname', () => {
- const navItem: NavItem = { text: 'Home', href: '/' }
- expect(isCurrentPage(navItem, '/blog/', mockPosts)).toBe(false)
- })
- })
-
- describe('blog post detection', () => {
- it('returns true when navItem is blog and pathname matches a post slug', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- expect(isCurrentPage(navItem, '/test-post/', mockPosts)).toBe(true)
- })
-
- it('returns true when navItem is blog and pathname matches another post slug', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- expect(isCurrentPage(navItem, '/another-post/', mockPosts)).toBe(true)
- })
-
- it('returns false when navItem is blog but pathname does not match any post slug', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- expect(isCurrentPage(navItem, '/unknown-page/', mockPosts)).toBe(false)
- })
-
- it('returns true when navItem is blog and pathname is exactly /blog/', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- expect(isCurrentPage(navItem, '/blog/', mockPosts)).toBe(true)
- })
-
- it('returns false when navItem is not blog and pathname matches a post slug', () => {
- const navItem: NavItem = { text: 'Home', href: '/' }
- expect(isCurrentPage(navItem, '/test-post/', mockPosts)).toBe(false)
- })
- })
-
- describe('edge cases', () => {
- it('returns false when posts array is empty and checking blog post match', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- expect(isCurrentPage(navItem, '/test-post/', [])).toBe(false)
- })
-
- it('handles pathnames without trailing slashes', () => {
- const navItem: NavItem = { text: 'Blog', href: '/blog/' }
- // This test assumes the actual pathname from Next.js includes the trailing slash
- // If the implementation needs to handle both, this test documents current behavior
- expect(isCurrentPage(navItem, '/test-post', mockPosts)).toBe(false)
- })
-
- it('returns false for empty pathname', () => {
- const navItem: NavItem = { text: 'Home', href: '/' }
- expect(isCurrentPage(navItem, '', mockPosts)).toBe(false)
- })
- })
-})
diff --git a/ui/nav/utils.ts b/ui/nav/utils.ts
deleted file mode 100644
index 6421d7a..0000000
--- a/ui/nav/utils.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import type { PostListItem } from '@/io/notion/schemas/post'
-import type { NavItem } from '@/ui/nav/types'
-
-/**
- * Determines if the given navigation item corresponds to the current page.
- */
-export function isCurrentPage(navItem: NavItem, pathname: string, posts: PostListItem[]): boolean {
- // Exact match?
- if (navItem.href === pathname) {
- return true
- }
-
- // Blog post?
- if (navItem.href === '/blog/') {
- return posts.some(post => pathname === `/${post.slug}/`)
- }
-
- // No match
- return false
-}
diff --git a/ui/post-list.test.tsx b/ui/sections/blog-post-list.test.tsx
similarity index 99%
rename from ui/post-list.test.tsx
rename to ui/sections/blog-post-list.test.tsx
index 151a3f0..f82ca75 100644
--- a/ui/post-list.test.tsx
+++ b/ui/sections/blog-post-list.test.tsx
@@ -4,7 +4,7 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
-import PostList from './post-list'
+import PostList from './blog-post-list'
import getPosts from '@/io/notion/getPosts'
import type { PostListItem } from '@/io/notion/schemas/post'
import { Ok, Err } from '@/utils/errors/result'
diff --git a/ui/post-list.tsx b/ui/sections/blog-post-list.tsx
similarity index 62%
rename from ui/post-list.tsx
rename to ui/sections/blog-post-list.tsx
index 22de8f4..95b8f12 100644
--- a/ui/post-list.tsx
+++ b/ui/sections/blog-post-list.tsx
@@ -1,8 +1,25 @@
import { type ReactElement } from 'react'
import getPosts from '@/io/notion/getPosts'
-import Link from '@/ui/link'
-import { getHumanReadableDate, getMachineReadableDate } from '@/utils/dates'
+import Link from '@/ui/elements/link'
+
+/**
+ * Given a date, returns a human-readable date string in the format "Jan 1, 2020".
+ */
+const getHumanReadableDate = (date: string | number | Date): string =>
+ new Date(date).toLocaleDateString('en-CA', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })
+
+/**
+ * Given a date, returns a machine-readable date string suitable for use in the `datetime` attribute of the `