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 }) => Michael Uloth, })) // 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 ( +