From e8a9fc8475de41c213ca1ce23f9b894971502c75 Mon Sep 17 00:00:00 2001 From: Michael Uloth Date: Fri, 26 Dec 2025 13:32:14 -0500 Subject: [PATCH 01/31] restructure: "metadata" -> "seo"; some "app" -> "ui"; some "utils" -> "io" --- .claude/CLAUDE.md | 44 +++++++ app/(prose)/[slug]/page.tsx | 4 +- app/(prose)/blog/page.tsx | 2 +- app/layout.tsx | 2 +- app/likes/page.tsx | 2 +- app/robots.ts | 2 +- app/rss.xml/route.ts | 2 +- app/sitemap.ts | 2 +- ci/metadata/validate.test.ts | 2 +- ci/metadata/validate.ts | 2 +- io/cloudinary/fetchCloudinaryImageMetadata.ts | 6 +- io/itunes/fetchItunesItems.ts | 4 +- {utils => io}/logging/logging.ts | 0 {utils => io}/logging/zod.test.ts | 0 {utils => io}/logging/zod.ts | 0 io/notion/getBlockChildren.ts | 4 +- io/notion/getMediaItems.ts | 4 +- io/notion/getPost.ts | 4 +- io/notion/getPosts.ts | 4 +- {utils => io}/retry.test.ts | 0 {utils => io}/retry.ts | 0 io/tmdb/fetchTmdbList.ts | 4 +- package.json | 2 +- .../metadata.test.ts => seo/constants.test.ts | 2 +- utils/metadata.ts => seo/constants.ts | 2 +- {metadata => seo}/fonts/Inter-Bold.ttf | Bin .../generate-og-image.tsx | 117 ++++++++---------- {utils => ui}/dates.ts | 0 ui/post-list.tsx | 2 +- .../[slug]/ui => ui/post}/post-footer.tsx | 0 .../[slug]/ui => ui/post}/post-header.tsx | 2 +- {app/(prose)/[slug]/ui => ui/post}/post.tsx | 0 32 files changed, 125 insertions(+), 96 deletions(-) create mode 100644 .claude/CLAUDE.md rename {utils => io}/logging/logging.ts (100%) rename {utils => io}/logging/zod.test.ts (100%) rename {utils => io}/logging/zod.ts (100%) rename {utils => io}/retry.test.ts (100%) rename {utils => io}/retry.ts (100%) rename utils/metadata.test.ts => seo/constants.test.ts (95%) rename utils/metadata.ts => seo/constants.ts (96%) rename {metadata => seo}/fonts/Inter-Bold.ttf (100%) rename metadata/generate-og-image.ts => seo/generate-og-image.tsx (51%) rename {utils => ui}/dates.ts (100%) rename {app/(prose)/[slug]/ui => ui/post}/post-footer.tsx (100%) rename {app/(prose)/[slug]/ui => ui/post}/post-header.tsx (90%) rename {app/(prose)/[slug]/ui => ui/post}/post.tsx (100%) 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/app/(prose)/[slug]/page.tsx b/app/(prose)/[slug]/page.tsx index 090ce07..9efc8c3 100644 --- a/app/(prose)/[slug]/page.tsx +++ b/app/(prose)/[slug]/page.tsx @@ -3,11 +3,11 @@ 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 { SITE_URL, SITE_NAME, SITE_AUTHOR, TWITTER_HANDLE, DEFAULT_OG_IMAGE } from '@/seo/constants' import { transformCloudinaryForOG } from '@/io/cloudinary/ogImageTransforms' import type { Post as PostType } from '@/io/notion/schemas/post' -import Post from './ui/post' +import Post from '@/ui/post/post' type Params = { slug: string diff --git a/app/(prose)/blog/page.tsx b/app/(prose)/blog/page.tsx index 02d335b..de5b98d 100644 --- a/app/(prose)/blog/page.tsx +++ b/app/(prose)/blog/page.tsx @@ -2,7 +2,7 @@ import { type Metadata } from 'next' import { type ReactElement } from 'react' import PostList from '@/ui/post-list' -import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/utils/metadata' +import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/seo/constants' export const metadata: Metadata = { title: 'Blog', diff --git a/app/layout.tsx b/app/layout.tsx index 01e0475..579a79c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,7 @@ import { type Metadata } from 'next' import { type ReactNode } from 'react' import '@/styles/globals.css' -import { SITE_URL, SITE_NAME, SITE_DESCRIPTION, SITE_AUTHOR, TWITTER_HANDLE, DEFAULT_OG_IMAGE } from '@/utils/metadata' +import { SITE_URL, SITE_NAME, SITE_DESCRIPTION, SITE_AUTHOR, TWITTER_HANDLE, DEFAULT_OG_IMAGE } from '@/seo/constants' export const metadata: Metadata = { metadataBase: new URL(SITE_URL), diff --git a/app/likes/page.tsx b/app/likes/page.tsx index 0f85a8b..8cb5c1a 100644 --- a/app/likes/page.tsx +++ b/app/likes/page.tsx @@ -5,7 +5,7 @@ import getMediaItems from '@/io/notion/getMediaItems' import fetchItunesItems, { type iTunesItem } from '@/io/itunes/fetchItunesItems' import { env } from '@/io/env/env' import { type Result } from '@/utils/errors/result' -import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/utils/metadata' +import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/seo/constants' export const metadata: Metadata = { title: 'Likes', diff --git a/app/robots.ts b/app/robots.ts index d207cce..54e1edc 100644 --- a/app/robots.ts +++ b/app/robots.ts @@ -1,5 +1,5 @@ import type { MetadataRoute } from 'next' -import { SITE_URL } from '@/utils/metadata' +import { SITE_URL } from '@/seo/constants' export const dynamic = 'force-static' diff --git a/app/rss.xml/route.ts b/app/rss.xml/route.ts index efa9da6..42b8596 100644 --- a/app/rss.xml/route.ts +++ b/app/rss.xml/route.ts @@ -2,7 +2,7 @@ import { Feed } from 'feed' import getPosts from '@/io/notion/getPosts' import getBlockChildren from '@/io/notion/getBlockChildren' import { renderBlocksToHtml } from '@/io/notion/renderBlocksToHtml' -import { SITE_URL } from '@/utils/metadata' +import { SITE_URL } from '@/seo/constants' export const dynamic = 'force-static' diff --git a/app/sitemap.ts b/app/sitemap.ts index 8e18916..d45d84c 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -1,6 +1,6 @@ import type { MetadataRoute } from 'next' import getPosts from '@/io/notion/getPosts' -import { SITE_URL } from '@/utils/metadata' +import { SITE_URL } from '@/seo/constants' export const dynamic = 'force-static' diff --git a/ci/metadata/validate.test.ts b/ci/metadata/validate.test.ts index 8c91a87..a339951 100644 --- a/ci/metadata/validate.test.ts +++ b/ci/metadata/validate.test.ts @@ -7,7 +7,7 @@ import { hasCorrectCloudinaryDimensions, validateOgImage, } from './validate' -import { SITE_URL } from '@/utils/metadata' +import { SITE_URL } from '@/seo/constants' describe('isValidUrl', () => { it('returns true for valid absolute URLs', () => { diff --git a/ci/metadata/validate.ts b/ci/metadata/validate.ts index cadff74..970779a 100755 --- a/ci/metadata/validate.ts +++ b/ci/metadata/validate.ts @@ -20,7 +20,7 @@ import { readFile, readdir } from 'fs/promises' import { existsSync } from 'fs' import { join } from 'path' import { OG_IMAGE_WIDTH, OG_IMAGE_HEIGHT } from '@/io/cloudinary/ogImageTransforms' -import { SITE_URL } from '@/utils/metadata' +import { SITE_URL } from '@/seo/constants' import { OUT_DIR, STATIC_PAGES, diff --git a/io/cloudinary/fetchCloudinaryImageMetadata.ts b/io/cloudinary/fetchCloudinaryImageMetadata.ts index adda280..2df31e5 100644 --- a/io/cloudinary/fetchCloudinaryImageMetadata.ts +++ b/io/cloudinary/fetchCloudinaryImageMetadata.ts @@ -1,12 +1,12 @@ import { filesystemCache, type CacheAdapter } from '@/io/cache/adapter' import cloudinary, { type CloudinaryClient } from '@/io/cloudinary/client' -import { getErrorDetails } from '@/utils/logging/logging' -import { formatValidationError } from '@/utils/logging/zod' +import { getErrorDetails } from '@/io/logging/logging' +import { formatValidationError } from '@/io/logging/zod' import parsePublicIdFromCloudinaryUrl from './parsePublicIdFromCloudinaryUrl' import { Ok, toErr, type Result } from '@/utils/errors/result' import { z } from 'zod' import { ImageEffect } from 'cloudinary' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' export const ERRORS = { FETCH_FAILED: '🚨 Error fetching Cloudinary image', diff --git a/io/itunes/fetchItunesItems.ts b/io/itunes/fetchItunesItems.ts index d45ca64..12a7453 100644 --- a/io/itunes/fetchItunesItems.ts +++ b/io/itunes/fetchItunesItems.ts @@ -1,8 +1,8 @@ import { z } from 'zod' import transformCloudinaryImage from '@/io/cloudinary/transformCloudinaryImage' -import { formatValidationError } from '@/utils/logging/zod' +import { formatValidationError } from '@/io/logging/zod' import { type Result, Ok, toErr } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' interface iTunesListItem { date: string diff --git a/utils/logging/logging.ts b/io/logging/logging.ts similarity index 100% rename from utils/logging/logging.ts rename to io/logging/logging.ts diff --git a/utils/logging/zod.test.ts b/io/logging/zod.test.ts similarity index 100% rename from utils/logging/zod.test.ts rename to io/logging/zod.test.ts diff --git a/utils/logging/zod.ts b/io/logging/zod.ts similarity index 100% rename from utils/logging/zod.ts rename to io/logging/zod.ts diff --git a/io/notion/getBlockChildren.ts b/io/notion/getBlockChildren.ts index 6e1e2ca..71281aa 100644 --- a/io/notion/getBlockChildren.ts +++ b/io/notion/getBlockChildren.ts @@ -6,9 +6,9 @@ import { type BulletedListBlock, type NumberedListBlock, } from './schemas/block' -import { logValidationError } from '@/utils/logging/zod' +import { logValidationError } from '@/io/logging/zod' import { Ok, toErr, type Result } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' export const INVALID_BLOCK_ERROR = 'Invalid block data - build aborted' diff --git a/io/notion/getMediaItems.ts b/io/notion/getMediaItems.ts index 20bc798..ceb43b6 100644 --- a/io/notion/getMediaItems.ts +++ b/io/notion/getMediaItems.ts @@ -8,10 +8,10 @@ import { DatePropertySchema, } from './schemas/properties' import { PageMetadataSchema } from './schemas/page' -import { logValidationError } from '@/utils/logging/zod' +import { logValidationError } from '@/io/logging/zod' import { env } from '@/io/env/env' import { type Result, Ok, toErr } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' type MediaCategory = 'books' | 'albums' | 'podcasts' diff --git a/io/notion/getPost.ts b/io/notion/getPost.ts index 795c294..81f44ac 100644 --- a/io/notion/getPost.ts +++ b/io/notion/getPost.ts @@ -4,10 +4,10 @@ import getBlockChildren from '@/io/notion/getBlockChildren' import getPosts from '@/io/notion/getPosts' import { PostListItemSchema, PostPropertiesSchema, type Post } from './schemas/post' import { PostPageMetadataSchema } from './schemas/page' -import { logValidationError } from '@/utils/logging/zod' +import { logValidationError } from '@/io/logging/zod' import { env } from '@/io/env/env' import { Ok, Err, toErr, type Result } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' type Options = { slug: string | null diff --git a/io/notion/getPosts.ts b/io/notion/getPosts.ts index ca46be2..d3b6f52 100644 --- a/io/notion/getPosts.ts +++ b/io/notion/getPosts.ts @@ -2,10 +2,10 @@ import { filesystemCache, type CacheAdapter } from '@/io/cache/adapter' import notion, { collectPaginatedAPI, type Client } from './client' import { PostListItemSchema, PostPropertiesSchema, type PostListItem } from './schemas/post' import { PageMetadataSchema } from './schemas/page' -import { logValidationError } from '@/utils/logging/zod' +import { logValidationError } from '@/io/logging/zod' import { env } from '@/io/env/env' import { type Result, Ok, toErr } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' type Options = { cache?: CacheAdapter diff --git a/utils/retry.test.ts b/io/retry.test.ts similarity index 100% rename from utils/retry.test.ts rename to io/retry.test.ts diff --git a/utils/retry.ts b/io/retry.ts similarity index 100% rename from utils/retry.ts rename to io/retry.ts diff --git a/io/tmdb/fetchTmdbList.ts b/io/tmdb/fetchTmdbList.ts index 4140212..8bd3df1 100644 --- a/io/tmdb/fetchTmdbList.ts +++ b/io/tmdb/fetchTmdbList.ts @@ -1,9 +1,9 @@ import { z } from 'zod' import transformCloudinaryImage from '@/io/cloudinary/transformCloudinaryImage' -import { formatValidationError } from '@/utils/logging/zod' +import { formatValidationError } from '@/io/logging/zod' import { env } from '@/io/env/env' import { type Result, Ok, Err, toErr } from '@/utils/errors/result' -import { withRetry } from '@/utils/retry' +import { withRetry } from '@/io/retry' // Schema for raw TMDB API response item const TmdbApiResultSchema = z.object({ diff --git a/package.json b/package.json index 7574605..38eafc4 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "deploy:production": "bash io/cloudflare/deploy-production-check.sh && npm run build && npx wrangler pages deploy out --project-name=michaeluloth --branch=main", "dev": "next dev", "format": "prettier --write .", - "generate:og-image": "tsx metadata/generate-og-image.ts", + "generate:og-image": "tsx seo/generate-og-image.tsx", "lighthouse": "lhci autorun || (tsx ci/lighthouse/parse-errors.ts && exit 1)", "prelighthouse": "tsx ci/lighthouse/generate-urls.ts", "lint": "eslint", diff --git a/utils/metadata.test.ts b/seo/constants.test.ts similarity index 95% rename from utils/metadata.test.ts rename to seo/constants.test.ts index 9ded120..3649162 100644 --- a/utils/metadata.test.ts +++ b/seo/constants.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { SITE_NAME, SITE_DESCRIPTION, SITE_AUTHOR, DEFAULT_OG_IMAGE, SITE_URL } from './metadata' +import { SITE_NAME, SITE_DESCRIPTION, SITE_AUTHOR, DEFAULT_OG_IMAGE, SITE_URL } from './constants' describe('metadata constants', () => { it('exports site name', () => { diff --git a/utils/metadata.ts b/seo/constants.ts similarity index 96% rename from utils/metadata.ts rename to seo/constants.ts index ca51ba0..d56c88b 100644 --- a/utils/metadata.ts +++ b/seo/constants.ts @@ -36,7 +36,7 @@ export const TWITTER_HANDLE = '@ooloth' /** * Default OpenGraph image (1200x630px). - * Generated via metadata/generate-og-image.ts from: + * Generated via seo/generate-og-image.ts from: * - Photo: Cloudinary (michael-landscape.jpg, color) * - Text: "Michael Uloth" in Inter Bold * - Background: zinc-900 (#18181b) diff --git a/metadata/fonts/Inter-Bold.ttf b/seo/fonts/Inter-Bold.ttf similarity index 100% rename from metadata/fonts/Inter-Bold.ttf rename to seo/fonts/Inter-Bold.ttf diff --git a/metadata/generate-og-image.ts b/seo/generate-og-image.tsx similarity index 51% rename from metadata/generate-og-image.ts rename to seo/generate-og-image.tsx index 85a54c6..5b28742 100644 --- a/metadata/generate-og-image.ts +++ b/seo/generate-og-image.tsx @@ -5,10 +5,11 @@ * - "Michael Uloth" in Inter font * - Accent color (#ff98a4) for visual interest * - * Run: npx tsx metadata/generate-og-image.ts + * Run: npx tsx seo/generate-og-image.tsx * Output: public/og-image.png */ +import React from 'react' import satori from 'satori' import sharp from 'sharp' import { readFile, writeFile } from 'fs/promises' @@ -21,7 +22,7 @@ const ZINC_900 = '#18181b' const ACCENT = '#ff98a4' async function fetchFont() { - const fontPath = join(process.cwd(), 'metadata', 'fonts', 'Inter-Bold.ttf') + const fontPath = join(process.cwd(), 'seo', 'fonts', 'Inter-Bold.ttf') return await readFile(fontPath) } @@ -41,71 +42,55 @@ async function generateOgImage() { console.log('Generating OG image with satori...') const svg = await satori( - { - type: 'div', - props: { - style: { - width: '100%', - height: '100%', +
+ {/* Text section */} +
+
+ Michael Uloth +
+
+
+ {/* Photo section */} + +
, { width: 1200, height: 630, diff --git a/utils/dates.ts b/ui/dates.ts similarity index 100% rename from utils/dates.ts rename to ui/dates.ts diff --git a/ui/post-list.tsx b/ui/post-list.tsx index 22de8f4..0cb3218 100644 --- a/ui/post-list.tsx +++ b/ui/post-list.tsx @@ -2,7 +2,7 @@ import { type ReactElement } from 'react' import getPosts from '@/io/notion/getPosts' import Link from '@/ui/link' -import { getHumanReadableDate, getMachineReadableDate } from '@/utils/dates' +import { getHumanReadableDate, getMachineReadableDate } from '@/ui/dates' type PostListProps = Readonly<{ limit?: number diff --git a/app/(prose)/[slug]/ui/post-footer.tsx b/ui/post/post-footer.tsx similarity index 100% rename from app/(prose)/[slug]/ui/post-footer.tsx rename to ui/post/post-footer.tsx diff --git a/app/(prose)/[slug]/ui/post-header.tsx b/ui/post/post-header.tsx similarity index 90% rename from app/(prose)/[slug]/ui/post-header.tsx rename to ui/post/post-header.tsx index f7f6bce..c74a362 100644 --- a/app/(prose)/[slug]/ui/post-header.tsx +++ b/ui/post/post-header.tsx @@ -1,6 +1,6 @@ import Heading from '@/ui/typography/heading' import Paragraph from '@/ui/typography/paragraph' -import { getHumanReadableDate } from '@/utils/dates' +import { getHumanReadableDate } from '@/ui/dates' type HeaderProps = Readonly<{ title: string diff --git a/app/(prose)/[slug]/ui/post.tsx b/ui/post/post.tsx similarity index 100% rename from app/(prose)/[slug]/ui/post.tsx rename to ui/post/post.tsx From da94047bbaea972f7f1f8cd2c659f328ed5b7951 Mon Sep 17 00:00:00 2001 From: Michael Uloth Date: Fri, 26 Dec 2025 14:12:35 -0500 Subject: [PATCH 02/31] restructure: "app" -> "ui" for almost every presentational component --- app/(prose)/layout.test.tsx | 54 ----------- app/(prose)/layout.tsx | 23 ----- app/{(prose) => }/[slug]/page.test.tsx | 3 + app/{(prose) => }/[slug]/page.tsx | 33 +++---- app/{(prose) => }/blog/page.test.tsx | 5 ++ app/{(prose) => }/blog/page.tsx | 11 ++- app/likes/layout.tsx | 17 ---- app/likes/page.test.tsx | 3 + app/likes/page.tsx | 99 ++++----------------- app/not-found.test.tsx | 10 +-- app/not-found.tsx | 32 ++----- app/{(prose) => }/page.test.tsx | 5 ++ app/page.tsx | 15 ++++ ui/home/recent-writing.tsx | 11 +++ app/(prose)/page.tsx => ui/home/summary.tsx | 22 +---- ui/layouts/page-layout.tsx | 36 ++++++++ ui/likes/media-section.tsx | 80 +++++++++++++++++ ui/not-found/not-found-content.tsx | 21 +++++ 18 files changed, 228 insertions(+), 252 deletions(-) delete mode 100644 app/(prose)/layout.test.tsx delete mode 100644 app/(prose)/layout.tsx rename app/{(prose) => }/[slug]/page.test.tsx (99%) rename app/{(prose) => }/[slug]/page.tsx (97%) rename app/{(prose) => }/blog/page.test.tsx (94%) rename app/{(prose) => }/blog/page.tsx (80%) delete mode 100644 app/likes/layout.tsx rename app/{(prose) => }/page.test.tsx (95%) create mode 100644 app/page.tsx create mode 100644 ui/home/recent-writing.tsx rename app/(prose)/page.tsx => ui/home/summary.tsx (73%) create mode 100644 ui/layouts/page-layout.tsx create mode 100644 ui/likes/media-section.tsx create mode 100644 ui/not-found/not-found-content.tsx diff --git a/app/(prose)/layout.test.tsx b/app/(prose)/layout.test.tsx deleted file mode 100644 index b7ccd61..0000000 --- a/app/(prose)/layout.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * @vitest-environment happy-dom - */ - -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import ProseLayout from './layout' - -// Mock the child components -vi.mock('@/ui/header', () => ({ - default: () =>
Header
, -})) - -vi.mock('@/ui/footer', () => ({ - default: () =>
Footer
, -})) - -describe('ProseLayout', () => { - describe('skip link', () => { - it('renders skip link with correct href and text', () => { - render(Test content) - - const skipLink = screen.getByRole('link', { name: /skip to main content/i }) - expect(skipLink).toBeInTheDocument() - expect(skipLink).toHaveAttribute('href', '#main') - }) - - it('has sr-only class for screen reader only visibility', () => { - render(Test content) - - const skipLink = screen.getByRole('link', { name: /skip to main content/i }) - expect(skipLink).toHaveClass('sr-only') - }) - - it('renders skip link as first element for keyboard navigation', () => { - const { container } = render(Test content) - - // Get the first anchor element in the container - const firstLink = container.querySelector('a') - expect(firstLink).toHaveTextContent('Skip to main content') - }) - }) - - it('renders children', () => { - render(Test child content) - expect(screen.getByText('Test child content')).toBeInTheDocument() - }) - - it('renders header and footer', () => { - render(Content) - expect(screen.getByText('Header')).toBeInTheDocument() - expect(screen.getByText('Footer')).toBeInTheDocument() - }) -}) diff --git a/app/(prose)/layout.tsx b/app/(prose)/layout.tsx deleted file mode 100644 index 3b8d19a..0000000 --- a/app/(prose)/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { type ReactNode } from 'react' -import Header from '@/ui/header' -import Footer from '@/ui/footer' - -type Props = Readonly<{ - children: ReactNode -}> - -export default function ProseLayout({ children }: Props) { - return ( -
- - Skip to main content - -
- {children} -
-
- ) -} diff --git a/app/(prose)/[slug]/page.test.tsx b/app/[slug]/page.test.tsx similarity index 99% rename from app/(prose)/[slug]/page.test.tsx rename to app/[slug]/page.test.tsx index 3b27495..1312f4b 100644 --- a/app/(prose)/[slug]/page.test.tsx +++ b/app/[slug]/page.test.tsx @@ -16,6 +16,9 @@ vi.mock('@/io/notion/getPost') vi.mock('next/navigation', () => ({ notFound: vi.fn(), })) +vi.mock('@/ui/layouts/page-layout', () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})) describe('generateStaticParams', () => { beforeEach(() => { diff --git a/app/(prose)/[slug]/page.tsx b/app/[slug]/page.tsx similarity index 97% rename from app/(prose)/[slug]/page.tsx rename to app/[slug]/page.tsx index 9efc8c3..80becae 100644 --- a/app/(prose)/[slug]/page.tsx +++ b/app/[slug]/page.tsx @@ -7,12 +7,26 @@ import { SITE_URL, SITE_NAME, SITE_AUTHOR, TWITTER_HANDLE, DEFAULT_OG_IMAGE } fr import { transformCloudinaryForOG } from '@/io/cloudinary/ogImageTransforms' import type { Post as PostType } from '@/io/notion/schemas/post' +import PageLayout from '@/ui/layouts/page-layout' import Post from '@/ui/post/post' type Params = { slug: string } +/** + * Generates the list of static params (slugs) for all blog posts. + * Replaces getStaticPaths in Next.js 13+ + * + * @returns A promise that resolves to an array of objects containing post slugs. + * @see https://nextjs.org/docs/app/api-reference/functions/generate-static-params + */ +export async function generateStaticParams(): Promise { + const posts = (await getPosts({ sortDirection: 'ascending' })).unwrap() + + return posts.map(post => ({ slug: post.slug })) +} + type Props = Readonly<{ params: Promise }> @@ -29,7 +43,8 @@ export default async function DynamicRoute({ params }: Props) { const jsonLd = generateJsonLd(post, slug) return ( - <> + + - __html: JSON.stringify(jsonLd).replace(/ - - ) -} - -/** - * Constructs the full URL for a blog post. - */ -function getPostUrl(slug: string): string { - return `${SITE_URL}${slug}/` -} - -type JsonLdArticle = { - '@context': string - '@type': string - headline: string - description: string - datePublished: string - dateModified: string - author: { - '@type': string - name: string - } - image: string - url: string -} - -/** - * Generates JSON-LD structured data for a blog post. - * @see https://schema.org/Article - */ -function generateJsonLd(post: PostType, slug: string): JsonLdArticle { - const url = getPostUrl(slug) - const rawImage = post.featuredImage ?? DEFAULT_OG_IMAGE - const image = transformCloudinaryForOG(rawImage) - - return { - '@context': 'https://schema.org', - '@type': 'Article', - headline: post.title, - description: post.description, - datePublished: post.firstPublished, - dateModified: post.lastEditedTime, - author: { - '@type': 'Person', - name: SITE_AUTHOR, - }, - image, - url, - } -} - export async function generateMetadata({ params }: Props): Promise { const slug = (await params).slug const post = (await getPost({ slug })).unwrap() if (!post) { + // TODO: confirm if this is the right behaviour; what pages would this apply to? return {} } @@ -125,7 +50,7 @@ export async function generateMetadata({ params }: Props): Promise { type: 'article', url, siteName: SITE_NAME, - locale: 'en_CA', + locale: 'en_CA', // TODO: replace with SITE_LOCALE title: post.title, description: post.description, publishedTime: post.firstPublished, @@ -142,3 +67,31 @@ export async function generateMetadata({ params }: Props): Promise { }, } } + +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 = generateArticleJsonLd(post, slug) + + return ( + + + + __html: JSON.stringify(jsonLd).replace(/ ) } diff --git a/app/page.tsx b/app/page.tsx index 310a698..da0c316 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,14 +2,24 @@ import { type ReactElement } from 'react' import PageLayout from '@/ui/layouts/page-layout' import Summary from '@/ui/home/summary' import RecentWriting from '@/ui/home/recent-writing' +import { generatePersonJsonLd } from '@/seo/json-ld/person' export default async function Home(): Promise { + const jsonLd = generatePersonJsonLd() + return (
+ - __html: JSON.stringify(jsonLd).replace(/ +
) } diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 6a860a3..2e65b0a 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -5,6 +5,7 @@ import PageLayout from '@/ui/layouts/page-layout' import PostList from '@/ui/post-list' import { DEFAULT_OG_IMAGE, SITE_NAME, SITE_URL, TWITTER_HANDLE } from '@/seo/constants' import { generateBlogJsonLd } from '@/seo/json-ld/blog' +import JsonLdScript from '@/seo/json-ld/script' export const metadata: Metadata = { title: 'Blog', @@ -32,13 +33,7 @@ export default async function Blog(): Promise {

Blog

- - __html: JSON.stringify(jsonLd).replace(/ +
) } diff --git a/seo/json-ld/script.test.tsx b/seo/json-ld/script.test.tsx new file mode 100644 index 0000000..bf76146 --- /dev/null +++ b/seo/json-ld/script.test.tsx @@ -0,0 +1,131 @@ +/** + * @vitest-environment happy-dom + */ + +import { describe, it, expect } from 'vitest' +import { render } from '@testing-library/react' +import JsonLdScript from './script' + +describe('JsonLdScript', () => { + it('renders script tag with correct type', () => { + const data = { '@context': 'https://schema.org', '@type': 'Person' } + const { container } = render() + + const script = container.querySelector('script') + expect(script).toBeTruthy() + expect(script?.type).toBe('application/ld+json') + }) + + it('stringifies JSON data correctly', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Person', + name: 'Test Name', + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"@context":"https://schema.org"') + expect(content).toContain('"@type":"Person"') + expect(content).toContain('"name":"Test Name"') + }) + + it('escapes < characters to prevent XSS', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Article', + description: 'Test with tag', + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + // The < should be escaped as \u003c + expect(content).toContain('\\u003c/script>') + // Should NOT contain unescaped + expect(content).not.toContain('') + }) + + it('handles Person schema structure', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Person', + name: 'Michael Uloth', + jobTitle: 'Software Engineer', + url: 'https://michaeluloth.com', + } + const { container } = render() + + const script = container.querySelector('script') + expect(script?.innerHTML).toContain('"jobTitle":"Software Engineer"') + }) + + it('handles Blog schema structure', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Blog', + name: 'My Blog', + author: { + '@type': 'Person', + name: 'Author Name', + }, + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"@type":"Blog"') + expect(content).toContain('"author":{') + }) + + it('handles Article schema structure', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Article', + headline: 'Test Article', + datePublished: '2024-01-15', + author: { + '@type': 'Person', + name: 'Author Name', + }, + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"headline":"Test Article"') + expect(content).toContain('"datePublished":"2024-01-15"') + }) + + it('handles arrays in JSON-LD data', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Person', + sameAs: ['https://github.com/user', 'https://twitter.com/user'], + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"sameAs":["https://github.com/user","https://twitter.com/user"]') + }) + + it('escapes multiple < characters', () => { + const data = { + '@context': 'https://schema.org', + '@type': 'Article', + description: 'Test and and
', + } + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + // All < should be escaped + expect(content).toContain('\\u003ctag>') + expect(content).toContain('\\u003c/script>') + expect(content).toContain('\\u003cdiv>') + // Should NOT contain any unescaped < + expect(content?.match(/[^\\] +}> + +/** + * Renders a JSON-LD script tag with proper XSS escaping. + * Escapes < characters to prevent XSS if content contains + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#embedding_data_in_html + */ +export default function JsonLdScript({ data }: JsonLdScriptProps) { + return ( + tag', - } - const { container } = render() - - const script = container.querySelector('script') - const content = script?.innerHTML - // The < should be escaped as \u003c - expect(content).toContain('\\u003c/script>') - // Should NOT contain unescaped - expect(content).not.toContain('') - }) - - it('handles Person schema structure', () => { - const data = { - '@context': 'https://schema.org', - '@type': 'Person', - name: 'Michael Uloth', - jobTitle: 'Software Engineer', - url: 'https://michaeluloth.com', - } - const { container } = render() - - const script = container.querySelector('script') - expect(script?.innerHTML).toContain('"jobTitle":"Software Engineer"') + describe('type="blog"', () => { + it('renders Blog schema script tag', () => { + const { container } = render() + + const script = container.querySelector('script') + expect(script).toBeTruthy() + expect(script?.type).toBe('application/ld+json') + }) + + it('generates Blog schema with correct structure', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"@context":"https://schema.org"') + expect(content).toContain('"@type":"Blog"') + expect(content).toContain('"name":"Michael Uloth\'s Blog"') + }) + + it('includes author as Person', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"author":{') + expect(content).toContain('"@type":"Person"') + }) }) - it('handles Blog schema structure', () => { - const data = { - '@context': 'https://schema.org', - '@type': 'Blog', - name: 'My Blog', - author: { - '@type': 'Person', - name: 'Author Name', - }, + describe('type="article"', () => { + const mockPost: Post = { + id: '123', + slug: 'test-post', + title: 'Test Article', + description: 'Test article description', + firstPublished: '2024-01-15', + lastEditedTime: '2024-01-20T10:00:00.000Z', + featuredImage: null, + blocks: [], + prevPost: null, + nextPost: null, } - const { container } = render() - const script = container.querySelector('script') - const content = script?.innerHTML - expect(content).toContain('"@type":"Blog"') - expect(content).toContain('"author":{') + it('renders Article schema script tag', () => { + const { container } = render() + + const script = container.querySelector('script') + expect(script).toBeTruthy() + expect(script?.type).toBe('application/ld+json') + }) + + it('generates Article schema with correct structure', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"@context":"https://schema.org"') + expect(content).toContain('"@type":"Article"') + expect(content).toContain('"headline":"Test Article"') + expect(content).toContain('"description":"Test article description"') + }) + + it('includes post dates', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"datePublished":"2024-01-15"') + expect(content).toContain('"dateModified":"2024-01-20T10:00:00.000Z"') + }) + + it('constructs URL from post slug', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"url":"https://michaeluloth.com/test-post/"') + }) + + it('includes author as Person', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + expect(content).toContain('"author":{') + expect(content).toContain('"@type":"Person"') + expect(content).toContain('"name":"Michael Uloth"') + }) }) - it('handles Article schema structure', () => { - const data = { - '@context': 'https://schema.org', - '@type': 'Article', - headline: 'Test Article', - datePublished: '2024-01-15', - author: { - '@type': 'Person', - name: 'Author Name', - }, - } - const { container } = render() - - const script = container.querySelector('script') - const content = script?.innerHTML - expect(content).toContain('"headline":"Test Article"') - expect(content).toContain('"datePublished":"2024-01-15"') - }) - - it('handles arrays in JSON-LD data', () => { - const data = { - '@context': 'https://schema.org', - '@type': 'Person', - sameAs: ['https://github.com/user', 'https://twitter.com/user'], - } - const { container } = render() - - const script = container.querySelector('script') - const content = script?.innerHTML - expect(content).toContain('"sameAs":["https://github.com/user","https://twitter.com/user"]') - }) - - it('escapes multiple < characters', () => { - const data = { - '@context': 'https://schema.org', - '@type': 'Article', - description: 'Test and and
', - } - const { container } = render() - - const script = container.querySelector('script') - const content = script?.innerHTML - // All < should be escaped - expect(content).toContain('\\u003ctag>') - expect(content).toContain('\\u003c/script>') - expect(content).toContain('\\u003cdiv>') - // Should NOT contain any unescaped < - expect(content?.match(/[^\\] { + it('escapes < characters in Person schema', () => { + const { container } = render() + + const script = container.querySelector('script') + const content = script?.innerHTML + // Should not contain unescaped < (except in the opening tag which is fine) + const contentWithoutTags = content?.replace(/<[^>]*>/g, '') + expect(contentWithoutTags?.includes('<')).toBe(false) + }) + + it('escapes < characters in Article schema with malicious content', () => { + const maliciousPost: Post = { + id: '123', + slug: 'test-post', + title: 'Test ', + 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({ data }: JsonLdScriptProps) { +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 (