1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-04-17 10:28:46 -04:00

2026 Redesign (#2531)

This commit is contained in:
2026-01-27 22:53:59 -05:00
committed by GitHub
parent d72e587401
commit 2ece5c79fa
27 changed files with 1887 additions and 2012 deletions

View File

@@ -1,97 +0,0 @@
This file provides guidance to GitHub Copilot when working with code in this repository.
## Project Overview
This is a personal website (jarv.is) built with Next.js, TypeScript, and various modern web technologies. The site uses the Next.js App Router and is designed to be deployed on Vercel. It includes features like blog posts (notes), projects showcase, contact form, and hit counter API.
## Development Commands
```bash
# Start development server
pnpm dev
# Build for production
pnpm build
# Start production server
pnpm start
# Lint code
pnpm lint
# Type check
pnpm typecheck
# Generate database migrations (using Drizzle)
pnpm db:generate
# Apply database migrations (using Drizzle)
pnpm db:migrate
```
## Environment Setup
The project requires several environment variables to function properly. The environment variables are documented and type-checked in `lib/env.ts`. Use `.env.example` as a template if a `.env` or `.env.local` file does not already exist.
Required server environment variables:
- `AUTH_SECRET`: Random value for authentication encryption
- `AUTH_GITHUB_CLIENT_ID`: Client ID from GitHub OAuth App
- `AUTH_GITHUB_CLIENT_SECRET`: Client secret from GitHub OAuth App
- `DATABASE_URL`: PostgreSQL connection string (Neon)
- `GITHUB_TOKEN`: GitHub API token for projects page
- `RESEND_API_KEY`: Resend API key for contact form
- `RESEND_TO_EMAIL`: Destination email for contact form
Required client environment variables:
- `NEXT_PUBLIC_GITHUB_REPO`: Repository in format "username/repo"
- `NEXT_PUBLIC_GITHUB_USERNAME`: GitHub username
## Architecture
### Core Technologies
- **Next.js App Router**: Modern React framework with server components
- **TypeScript**: Type safety throughout the project
- **Tailwind CSS**: Styling with utility classes
- **Drizzle ORM**: Database interactions with type safety
- **Better Auth**: Authentication system
- **MDX**: For notes/blog content with enhanced features
- **Neon PostgreSQL**: Serverless database
### Database Schema
The Drizzle ORM database schema is defined in `lib/db/schema.ts`.
### Content Structure
- `/app`: Next.js App Router pages and layouts
- `/components`: React components organized by feature
- `/lib`: Utility functions and configurations
- `/notes`: MDX content for blog posts
- `/public`: Static assets
## Development Considerations
1. When using ANY library, always use `use context7` to lookup documentation from the context7 MCP server, which provides access to all project-specific configuration files and standards
2. Always prefer React Server Components (RSC) over client components
3. React components follow patterns from Tailwind v4 with shadcn/ui components
4. The project assumes deployment to Vercel and makes use of Vercel-specific features
5. When working with MDX content, note the custom plugins and transformations in `next.config.ts`
6. Database operations use Drizzle ORM with Neon's serverless PostgreSQL client
7. The repository uses strict linting and type checking through ESLint and TypeScript
## External Documentation Lookup
1. The Context7 MCP server is available to reference documentation for any library or dependency
2. Before installing any package, running commands, or creating/updating dependency files, you MUST use `use context7` to retrieve the most up-to-date and authoritative documentation for the relevant stack, library, or technology
3. Do NOT rely solely on model training data or general knowledge; Context7 must be consulted for all dependency and setup actions

161
AGENTS.md Normal file
View File

@@ -0,0 +1,161 @@
# AGENTS.md
Guidelines for AI coding agents working in this repository.
## Build & Development Commands
```bash
pnpm dev # Development server (binds to 0.0.0.0)
pnpm build # Production build
pnpm typecheck # Type checking
pnpm lint # Lint entire codebase
pnpm lint path/to/file.tsx # Lint a single file
pnpm db:generate # Generate migration files
pnpm db:migrate # Apply migrations
```
No test suite exists. Validate changes via `pnpm typecheck` and `pnpm lint`.
## Code Style
### Formatting (Prettier)
- **Line width:** 120 characters
- **Indentation:** 2 spaces (no tabs)
- **Quotes:** Double quotes, no JSX single quotes
- **Trailing commas:** ES5 style
- **Semicolons:** Required
- **Tailwind:** Classes auto-sorted via `prettier-plugin-tailwindcss`
### Import Organization
```typescript
// 1. React/Next.js core
import { useState } from "react";
// 2. External libraries
import { eq, desc } from "drizzle-orm";
// 3. Internal modules using @/ alias
import { db } from "@/lib/db";
import Button from "@/components/ui/button";
```
- Always use `@/` path aliases (never relative `../../`)
- Use `type` keyword for type-only imports: `import type { Foo } from "bar"`
### TypeScript
- **Strict mode** enabled - no implicit any, strict null checks
- **Explicit return types** for API routes and server actions
- **Zod** for runtime validation - share schemas between client and server
- Avoid `any` - use `unknown` with type guards when necessary
- Use `as const` for immutable config objects
### Naming Conventions
| Element | Convention | Example |
| ------------------- | ------------------------ | ---------------------------- |
| Files | kebab-case | `contact-form.tsx` |
| Components | PascalCase | `ContactForm` |
| Functions/Variables | camelCase | `getComments`, `isPending` |
| Types/Interfaces | PascalCase | `CommentWithUser` |
| Constants | camelCase or UPPER_SNAKE | `siteConfig`, `DATABASE_URL` |
### Component Patterns
**Prefer Server Components** - Only add `"use client"` when necessary for:
- Event handlers (onClick, onChange, onSubmit)
- React hooks (useState, useEffect, useTransition)
- Browser-only APIs
**Component structure:**
- Arrow function components with default exports
- Props destructured in parameters
- Use `cn()` from `@/lib/utils` for className merging
- Follow shadcn/ui patterns for UI components
```typescript
const Button = ({
className,
variant,
...rest
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) => {
return <button className={cn(buttonVariants({ variant, className }))} {...rest} />;
};
export default Button;
```
### Server Actions & Error Handling
```typescript
"use server";
export type ActionState = { success: boolean; message: string; errors?: Record<string, string[]> };
export const submitForm = async (state: ActionState, payload: FormData): Promise<ActionState> => {
try {
const data = Schema.safeParse(Object.fromEntries(payload));
if (!data.success) {
return { success: false, message: "Validation failed", errors: data.error.flatten().fieldErrors };
}
// Perform action...
return { success: true, message: "Success!" };
} catch (error) {
console.error("[server/action] error:", error);
return { success: false, message: "Internal server error" };
}
};
```
**Client-side:** Use `toast` from sonner for feedback, `useTransition` for pending states.
### Database (Drizzle ORM)
- Schema in `lib/db/schema.ts`
- Use `"use cache"` directive with `cacheTag()` for cached queries
- Use `revalidatePath()` and `revalidateTag()` after mutations
```typescript
export const getData = async (slug: string) => {
"use cache";
cacheTag("data", `data-${slug}`);
return db.select().from(schema.table).where(eq(schema.table.slug, slug));
};
```
### Environment Variables
Type-checked in `lib/env.ts` using `@t3-oss/env-nextjs`. Never access `process.env` directly:
```typescript
import { env } from "@/lib/env";
const apiKey = env.RESEND_API_KEY;
```
## Project Structure
```
app/ # Next.js App Router pages and API routes
components/ # React components organized by feature
ui/ # shadcn/ui base components
layout/ # Header, footer, page layout
comments/ # Comment system components
lib/ # Core utilities and configuration
db/ # Drizzle schema and database client
server/ # Server actions (contact, comments, views)
config/ # Site and author configuration
schemas/ # Shared Zod validation schemas
notes/ # MDX blog content files
```
## Key Dependencies
- **Next.js 15+** with App Router and React 19
- **Tailwind CSS v4** with shadcn/ui components
- **Drizzle ORM** with Neon PostgreSQL
- **Better Auth** for GitHub OAuth authentication
- **Zod** for schema validation
- **MDX** for blog content with remark/rehype plugins

View File

@@ -1,73 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
```bash
# Start development server
pnpm dev
# Build for production
pnpm build
# Type check
pnpm typecheck
# Lint code
pnpm lint
# Generate database migrations
pnpm db:generate
# Apply database migrations
pnpm db:migrate
```
## Environment Setup
Environment variables are strictly type-checked in `lib/env.ts`. Copy `.env.example` to `.env` or `.env.local` and populate required values.
Key required variables:
- `DATABASE_URL`: PostgreSQL connection string (Neon)
- `AUTH_SECRET`: Random value for authentication encryption
- `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`: GitHub OAuth App credentials
- `RESEND_API_KEY` / `RESEND_TO_EMAIL`: For contact form emails
- `NEXT_PUBLIC_GITHUB_REPO` / `NEXT_PUBLIC_GITHUB_USERNAME`: For projects page
## Architecture
### Tech Stack
- **Next.js App Router**: Server components with TypeScript
- **Tailwind CSS v4**: Utility-first styling with shadcn/ui components
- **Drizzle ORM**: Type-safe database interactions with Neon PostgreSQL
- **Better Auth**: Authentication with GitHub OAuth
- **MDX**: Enhanced markdown for blog posts with custom plugins
### Key Structure
- `/app`: App Router pages and API routes
- `/components`: Feature-organized React components
- `/lib`: Core utilities, configs, and database schema
- `/notes`: MDX blog content files
- Database schema: `lib/db/schema.ts` with users, sessions, comments, and page views
### Content & Features
- Blog posts (`/notes`) are MDX files with frontmatter
- Comments system with nested threads
- Hit counter API (`/api/hits`)
- GitHub projects showcase
- Contact form with Vercel BotID protection
- RSS/Atom feeds
- Theme toggle (dark/light mode)
## Development Notes
- Always prefer React Server Components over client components
- Database operations use Drizzle ORM with Neon's serverless client
- MDX processing includes custom remark/rehype plugins configured in `next.config.ts`
- Deployment assumes Vercel with specific environment handling
- Strict ESLint and TypeScript configuration enforced

1
CLAUDE.md Symbolic link
View File

@@ -0,0 +1 @@
AGENTS.md

View File

@@ -11,35 +11,39 @@ export const metadata = createMetadata({
const Page = () => {
return (
<div className="w-full md:mx-auto md:w-2/3">
<>
<PageTitle canonical="/contact">Contact</PageTitle>
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base">
Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "}
<Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "}
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
🦋 Bluesky
</Link>{" "}
or{" "}
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
🦣 Mastodon
</Link>
.
</p>
<p className="my-5 text-[0.925rem] leading-relaxed md:text-base">
You can grab my public key here:{" "}
<Link
href="https://jrvs.io/pgp"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]"
>
2B0C 9CF2 51E6 9A39
</Link>
.
</p>
<div className="w-full md:mx-auto md:w-2/3">
<div className="prose prose-sm prose-neutral dark:prose-invert max-w-none">
<p>
Fill out this quick form and I&rsquo;ll get back to you as soon as I can! You can also{" "}
<Link href="mailto:jake@jarv.is">email me directly</Link> or send me a direct message on{" "}
<Link href="https://bsky.app/profile/jarv.is" className="text-nowrap">
Bluesky
</Link>{" "}
or{" "}
<Link href="https://fediverse.jarv.is/@jake" className="text-nowrap">
Mastodon
</Link>
.
</p>
<p>
You can grab my public key here:{" "}
<Link
href="https://jrvs.io/pgp"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
className="bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] font-mono text-sm font-medium tracking-wider [word-spacing:-0.25em]"
>
2B0C 9CF2 51E6 9A39
</Link>
.
</p>
</div>
<ContactForm />
</div>
<ContactForm />
</div>
</>
);
};

View File

@@ -1,66 +1,22 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";
@custom-variant dark (&:where([data-theme=dark] *));
:root {
--background: oklch(1.00 0 0);
--foreground: oklch(0.26 0 0);
--card: oklch(1.00 0 0);
--card-foreground: oklch(0.26 0 0);
--popover: oklch(1.00 0 0);
--popover-foreground: oklch(0.26 0 0);
--primary: oklch(0.50 0.13 245.46);
--primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.98 0 0);
--secondary-foreground: oklch(0.33 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.98 0 0);
--accent-foreground: oklch(0.33 0 0);
--highlight: oklch(0.50 0.13 245.46);
--highlight-foreground: oklch(0.99 0 0);
--destructive: oklch(0.62 0.21 25.77);
--warning: oklch(0.67 0.179 58.318);
--success: oklch(0.63 0.194 149.214);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--radius: 0.625rem;
}
[data-theme="dark"] {
--background: oklch(0.20 0 0);
--foreground: oklch(0.98 0 0);
--card: oklch(0.14 0.00 285.82);
--card-foreground: oklch(0.98 0 0);
--popover: oklch(0.14 0.00 285.82);
--popover-foreground: oklch(0.98 0 0);
--primary: oklch(0.81 0.10 251.81);
--primary-foreground: oklch(0.21 0.01 285.88);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.27 0.01 286.03);
--accent-foreground: oklch(0.98 0 0);
--highlight: oklch(0.81 0.10 251.81);
--highlight-foreground: oklch(0.21 0.01 285.88);
--destructive: oklch(0.70 0.19 22.23);
--warning: oklch(0.8 0.184 86.047);
--success: oklch(0.79 0.209 151.711);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
}
@custom-variant dark (&:where(.dark *));
@theme inline {
--font-sans: var(--font-geist-sans);
--font-sans--font-feature-settings: "rlig" 1, "calt" 0;
--font-mono: var(--font-geist-mono);
--font-mono--font-feature-settings: "liga" 0;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -75,24 +31,123 @@
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-highlight: var(--highlight);
--color-highlight-foreground: var(--highlight-foreground);
--color-destructive: var(--destructive);
--color-warning: var(--warning);
--color-success: var(--success);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-surface: var(--surface);
--color-surface-foreground: var(--surface-foreground);
--color-code: var(--code);
--color-code-foreground: var(--code-foreground);
--color-code-highlight: var(--code-highlight);
--color-code-number: var(--code-number);
--color-selection: var(--selection);
--color-selection-foreground: var(--selection-foreground);
}
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.97 0.01 17);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--surface: oklch(0.98 0 0);
--surface-foreground: var(--foreground);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.96 0 0);
--code-number: oklch(0.56 0 0);
--selection: oklch(0.145 0 0);
--selection-foreground: oklch(1 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.269 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.371 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.58 0.22 27);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: var(--color-blue-300);
--chart-2: var(--color-blue-500);
--chart-3: var(--color-blue-600);
--chart-4: var(--color-blue-700);
--chart-5: var(--color-blue-800);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.439 0 0);
--surface: oklch(0.2 0 0);
--surface-foreground: oklch(0.708 0 0);
--code: var(--surface);
--code-foreground: var(--surface-foreground);
--code-highlight: oklch(0.27 0 0);
--code-number: oklch(0.72 0 0);
--selection: oklch(0.922 0 0);
--selection-foreground: oklch(0.205 0 0);
}
@theme {
--animate-wave: wave 5s ease 1s infinite;
--animate-heartbeat: heartbeat 10s ease 7.5s infinite;
--animate-marquee: marquee 30s linear infinite;
@keyframes wave {
@@ -118,21 +173,6 @@
}
}
@keyframes heartbeat {
0%,
4%,
8%,
100% {
transform: scale(1);
}
2% {
transform: scale(1.25);
}
6% {
transform: scale(1.2);
}
}
@keyframes marquee {
from {
transform: translateX(0);
@@ -147,9 +187,11 @@
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground selection:bg-highlight selection:text-highlight-foreground;
@apply bg-background text-foreground;
}
::selection {
@apply bg-selection text-selection-foreground;
}
::-webkit-scrollbar {
@@ -169,10 +211,114 @@
}
@layer components {
@import "react-lite-youtube-embed/dist/LiteYouTubeEmbed.css";
[data-slot="code-block"] {
@apply bg-code text-code-foreground overflow-x-auto overflow-y-hidden rounded-xl text-sm outline-none;
/* styles for wrapper around generated markdown content */
.generated {
@apply text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5;
/* Override shiki inline styles */
& pre {
@apply m-0 rounded-xl !bg-transparent;
}
/* Dark mode token colors - override shiki inline color styles */
& span[style*="color"] {
@apply dark:![color:var(--shiki-dark)];
}
& code {
display: grid;
min-width: 100%;
white-space: pre;
padding: 0.875rem 1rem;
counter-reset: line;
}
& .line {
display: inline-block;
min-height: 1lh;
width: 100%;
padding-block: 0.125rem;
}
/* Highlighted lines */
& .line.highlighted {
position: relative;
background-color: var(--color-code-highlight);
&:after {
position: absolute;
top: 0;
left: 0;
width: 2px;
height: 100%;
content: "";
background-color: color-mix(in oklab, var(--muted-foreground) 50%, transparent);
}
}
/* Highlighted words */
& .highlighted-word {
position: relative;
background-color: var(--color-code-highlight);
border-radius: var(--radius-sm);
padding-inline: 0.3rem;
padding-block: 0.1rem;
}
}
/* Line numbers - only when data-line-numbers is set */
[data-slot="code-block"][data-line-numbers] .line::before {
font-size: var(--text-sm);
counter-increment: line;
content: counter(line);
display: inline-block;
width: 2rem;
margin-right: 1.5rem;
text-align: right;
color: var(--color-code-number);
}
[data-slot="code-block"][data-line-numbers] .line.highlighted::before {
background-color: var(--color-code-highlight);
}
}
/* View Transitions */
@layer base {
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}
main {
view-transition-name: main-content;
}
::view-transition-old(main-content) {
animation: fade-out 120ms ease-out;
}
::view-transition-new(main-content) {
animation: fade-in 120ms ease-in 40ms;
}
@keyframes fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
}

View File

@@ -1,7 +1,6 @@
import { env } from "@/lib/env";
import { JsonLd } from "react-schemaorg";
import Providers from "@/components/providers";
import { ThemeScript } from "@/components/theme/theme-script";
import Header from "@/components/layout/header";
import Footer from "@/components/layout/footer";
import Toaster from "@/components/ui/sonner";
@@ -24,8 +23,6 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
suppressHydrationWarning
>
<head>
<ThemeScript />
<JsonLd<Person>
item={{
"@context": "https://schema.org",
@@ -65,13 +62,11 @@ const RootLayout = ({ children }: Readonly<{ children: React.ReactNode }>) => {
<body className="bg-background text-foreground font-sans antialiased">
<Providers>
<div className="mx-auto w-full max-w-4xl px-5 py-1">
<Header className="mt-4 mb-6 w-full" />
<Header />
<div className="mx-auto mt-4 w-full max-w-4xl px-5">
<main>{children}</main>
<Footer className="my-6 w-full" />
</div>
<Footer />
<Toaster position="bottom-center" hotkey={[]} />
<Analytics />

View File

@@ -1,3 +1,4 @@
import Button from "@/components/ui/button";
import Video from "@/components/video";
import Link from "@/components/link";
import type { Metadata } from "next";
@@ -23,9 +24,9 @@ const Page = () => {
<div className="mt-6 text-center">
<h1 className="my-2 text-2xl font-semibold md:text-3xl">Page Not Found</h1>
<p className="mt-4 mb-0 text-lg font-medium md:text-xl">
<Button className="mt-4 mb-0 text-[15px] leading-none" size="lg" asChild>
<Link href="/">Go home?</Link>
</p>
</Button>
</div>
</>
);

View File

@@ -61,8 +61,10 @@ const getFormattedDates = async (date: string) => {
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params;
const frontmatter = await getFrontMatter(slug);
const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`);
const [frontmatter, commentCount] = await Promise.all([
getFrontMatter(slug),
getCommentCounts(`${POSTS_DIR}/${slug}`),
]);
const formattedDates = await getFormattedDates(frontmatter!.date);
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
@@ -99,7 +101,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
href={`/${POSTS_DIR}/${frontmatter!.slug}`}
className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"}
>
<CalendarDaysIcon className="inline size-4 shrink-0" />
<CalendarDaysIcon className="inline size-4 shrink-0" aria-hidden="true" />
<time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle}>
{formattedDates.dateDisplay}
</time>
@@ -107,7 +109,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
{frontmatter!.tags && (
<div className="flex flex-wrap items-center gap-x-2 whitespace-nowrap">
<TagIcon className="inline size-4 shrink-0" />
<TagIcon className="inline size-4 shrink-0" aria-hidden="true" />
{frontmatter!.tags.map((tag) => (
<span
key={tag}
@@ -126,7 +128,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
title={`Edit "${frontmatter!.title}" on GitHub`}
className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"}
>
<SquarePenIcon className="inline size-4 shrink-0" />
<SquarePenIcon className="inline size-4 shrink-0" aria-hidden="true" />
<span>Improve This Post</span>
</Link>
@@ -135,17 +137,17 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)} ${commentCount === 1 ? "comment" : "comments"}`}
className="text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"
>
<MessagesSquareIcon className="inline size-4 shrink-0" />
<MessagesSquareIcon className="inline size-4 shrink-0" aria-hidden="true" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(commentCount || 0)}</span>
</Link>
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
<EyeIcon className="inline size-4 shrink-0" />
<EyeIcon className="inline size-4 shrink-0" aria-hidden="true" />
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</div>
</div>
<h1 className="mt-2 mb-3 text-3xl/10 font-bold md:text-4xl/12">
<h1 className="mt-4 mb-5 text-4xl font-semibold tracking-tight sm:text-3xl">
<Link
href={`/${POSTS_DIR}/${frontmatter!.slug}`}
dangerouslySetInnerHTML={{ __html: frontmatter!.htmlTitle || frontmatter!.title }}

View File

@@ -24,8 +24,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
<>
{views > 0 && (
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
<EyeIcon className="inline-block size-4 shrink-0" />
<span className="inline-block leading-none">{numberFormatter.format(views)}</span>
<EyeIcon className="inline-block size-4 shrink-0" aria-hidden="true" />
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(views)}</span>
</span>
)}
@@ -37,8 +37,8 @@ const PostStats = ({ views, comments, slug }: { views: number; comments: number;
className="inline-flex hover:no-underline"
>
<span className="bg-muted text-foreground/65 inline-flex h-5 flex-nowrap items-center gap-1 rounded-md px-1.5 align-text-top text-xs font-semibold text-nowrap shadow select-none">
<MessagesSquareIcon className="inline-block size-3 shrink-0" />
<span className="inline-block leading-none">{numberFormatter.format(comments)}</span>
<MessagesSquareIcon className="inline-block size-3 shrink-0" aria-hidden="true" />
<span className="inline-block leading-none tabular-nums">{numberFormatter.format(comments)}</span>
</span>
</Link>
)}
@@ -96,8 +96,8 @@ const PostsList = async () => {
Object.entries(postsByYear).forEach(([year, posts]) => {
sections.push(
<section className="my-8 first-of-type:mt-6 last-of-type:mb-6" key={year}>
<h2 id={year} className="mt-0 mb-4 text-3xl font-bold md:text-4xl">
<section className="my-8 first-of-type:mt-0 last-of-type:mb-0" key={year}>
<h2 id={year} className="mt-0 mb-4 text-4xl font-semibold tracking-tight sm:text-3xl">
{year}
</h2>
<ul className="space-y-4">
@@ -114,6 +114,7 @@ const PostsList = async () => {
href={`/${POSTS_DIR}/${slug}`}
prefetch={false}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
className="inline-flex items-center gap-2 text-lg font-medium underline-offset-4 hover:underline md:text-base"
/>
<PostStats slug={slug} views={views} comments={comments} />

View File

@@ -3,109 +3,50 @@ import { LockIcon } from "lucide-react";
const Page = () => {
return (
<>
<h1 className="mt-0 mb-2 text-3xl leading-relaxed font-medium">
<div className="prose prose-sm prose-neutral dark:prose-invert prose-headings:mt-0 prose-headings:mb-3 prose-p:my-3 prose-p:leading-[1.75] md:prose-p:leading-relaxed max-w-none">
<h1 className="text-2xl font-medium">
Hi there! I&rsquo;m Jake.{" "}
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-3xl">👋</span>
<span className="motion-safe:animate-wave ml-0.5 inline-block origin-[65%_80%] text-2xl">👋</span>
</h1>
<h2 className="my-2 text-xl leading-relaxed font-normal">
<h2 className="font-normal">
I&rsquo;m a frontend web developer based in the{" "}
<Link
href="https://www.youtube-nocookie.com/embed/rLwbzGyC6t4?hl=en&amp;fs=1&amp;showinfo=1&amp;rel=0&amp;iv_load_policy=3"
title='"Boston Accent Trailer - Late Night with Seth Meyers" on YouTube'
className="[--primary:#fb4d42] dark:[--primary:#ff5146]"
>
Boston
</Link>{" "}
area.
</h2>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
I specialize in using{" "}
<Link href="https://www.typescriptlang.org/" className="[--primary:#235a97] dark:[--primary:#59a8ff]">
TypeScript
</Link>
,{" "}
<Link href="https://reactjs.org/" className="[--primary:#1091b3] dark:[--primary:#6fcbe3]">
React
</Link>
, and{" "}
<Link href="https://nextjs.org/" className="[--primary:#5e7693] dark:[--primary:#a8b9c0]">
Next.js
</Link>{" "}
to make lightweight{" "}
<Link href="https://jamstack.org/glossary/jamstack/" className="[--primary:#04a699] dark:[--primary:#08bbac]">
Jamstack sites
</Link>{" "}
with dynamic and powerful{" "}
<Link href="https://nodejs.org/en/" className="[--primary:#6fbc4e] dark:[--primary:#84d95f]">
Node
</Link>{" "}
backends. But I still know my way around{" "}
<Link
href="https://www.jetbrains.com/lp/php-25/"
title="25 Years of PHP History"
className="[--primary:#8892bf] dark:[--primary:#a4afe3]"
>
less buzzwordy
</Link>{" "}
stacks (and{" "}
<Link
href="https://timkadlec.com/remembers/2020-04-21-the-cost-of-javascript-frameworks/"
title='"The Cost of Javascript Frameworks" by Tim Kadlec'
className="[--primary:#f48024] dark:[--primary:#e18431]"
>
vanilla JavaScript
</Link>
), too.
<p>
I specialize in using TypeScript, React, and Next.js to make lightweight frontends with dynamic and powerful
backends.
</p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
<p>
Whenever possible, I also apply my experience in{" "}
<Link
href="https://bugcrowd.com/jakejarvis"
title="Jake Jarvis on Bugcrowd"
className="[--primary:#00b81a] dark:[--primary:#57f06d]"
>
<Link href="https://bugcrowd.com/jakejarvis" title="Jake Jarvis on Bugcrowd">
information security
</Link>
,{" "}
<Link
href="https://www.cloudflare.com/learning/serverless/what-is-serverless/"
title='"What is serverless computing?" on Cloudflare'
className="[--primary:#0098ec] dark:[--primary:#43b9fb]"
>
serverless architecture
</Link>
, and{" "}
</Link>{" "}
and{" "}
<Link
href="https://github.com/jakejarvis?tab=repositories&q=github-actions&type=&language=&sort=stargazers"
title='My repositories tagged with "github-actions" on GitHub'
className="[--primary:#ff6200] dark:[--primary:#f46c16]"
>
automation
devops
</Link>
.
</p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
<p>
I fell in love with{" "}
<Link
href="/previously"
prefetch={false}
title="My Terrible, Horrible, No Good, Very Bad First Websites"
className="[--primary:#4169e1] dark:[--primary:#8ca9ff]"
>
<Link href="/previously" prefetch={false} title="My Terrible, Horrible, No Good, Very Bad First Websites">
frontend web design
</Link>{" "}
and{" "}
<Link
href="/notes/my-first-code"
prefetch={false}
title="Jake's Bulletin Board, circa 2003"
className="[--primary:#9932cc] dark:[--primary:#d588fb]"
>
<Link href="/notes/my-first-code" prefetch={false} title="Jake's Bulletin Board, circa 2003">
backend coding
</Link>{" "}
when my only source of income was{" "}
@@ -113,131 +54,61 @@ const Page = () => {
href="/birthday"
prefetch={false}
title="🎉 Cranky Birthday Boy on VHS Tape 📼"
className="[--primary:#e40088] dark:[--primary:#fd40b1]"
style={{
cursor: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='30' style='font-size:24px'><text y='50%' transform='rotate(-70 0 0) translate(-20, 6)'>🪄</text></svg>") 5 5, auto`,
}}
className="font-normal no-underline"
>
the Tooth Fairy
</Link>
. <span className="text-muted-foreground">I&rsquo;ve improved a bit since then, I think?</span>
. <span className="text-muted-foreground">(I&rsquo;ve improved a bit since then, I think?)</span>
</p>
<p className="my-3 text-base leading-relaxed md:text-[0.975rem]">
<p>
I&rsquo;m currently building{" "}
<Link
href="https://domainstack.io"
title="Domainstack: Domain intelligence made easy"
className="font-medium [--primary:#a054d0] dark:[--primary:#dd9fff]"
>
<Link href="https://domainstack.io" title="Domainstack: Domain intelligence made easy" className="font-medium">
Domainstack
</Link>
, a beautiful all-in-one domain name intelligence tool, and{" "}
<Link
href="https://snoozle.ai"
title="Snoozle: AI-powered bedtime stories for children"
className="bg-gradient-to-r from-purple-500 to-pink-500 bg-clip-text font-medium text-transparent [--primary:#a855f7] dark:from-purple-400 dark:to-pink-400 dark:[--primary:#c084fc]"
className="font-medium"
>
Snoozle
</Link>
, an AI-powered bedtime story generator. Over the years, some of my other side projects{" "}
<Link
href="/leo"
prefetch={false}
title="Powncer segment on The Lab with Leo Laporte (G4techTV)"
className="[--primary:#ff1b1b] dark:[--primary:#f06060]"
>
have
</Link>{" "}
<Link
href="https://tuftsdaily.com/news/2012/04/06/student-designs-iphone-joeytracker-app/"
title='"Student designs iPhone JoeyTracker app" on The Tufts Daily'
className="[--primary:#f78200] dark:[--primary:#fd992a]"
>
been
</Link>{" "}
<Link
href="https://www.google.com/books/edition/The_Facebook_Effect/RRUkLhyGZVgC?hl=en&gbpv=1&dq=%22jake%20jarvis%22&pg=PA226&printsec=frontcover&bsq=%22jake%20jarvis%22"
title='"The Facebook Effect" by David Kirkpatrick (Google Books)'
className="[--primary:#f2b702] dark:[--primary:#ffcc2e]"
>
featured
</Link>{" "}
<Link
href="https://money.cnn.com/2007/06/01/technology/facebookplatform.fortune/index.htm"
title='"The new Facebook is on a roll" on CNN Money'
className="[--primary:#5ebd3e] dark:[--primary:#78df55]"
>
by
</Link>{" "}
<Link
href="https://www.wired.com/2007/04/our-web-servers/"
title='"Middio: A YouTube Scraper for Major Label Music Videos" on Wired'
className="[--primary:#009cdf] dark:[--primary:#29bfff]"
>
various
</Link>{" "}
<Link
href="https://gigaom.com/2009/10/06/fresh-faces-in-tech-10-kid-entrepreneurs-to-watch/6/"
title='"Fresh Faces in Tech: 10 Kid Entrepreneurs to Watch" on Gigaom'
className="[--primary:#3e49bb] dark:[--primary:#7b87ff]"
>
media
</Link>{" "}
<Link
href="https://adage.com/article/small-agency-diary/client-ceo-s-son/116723/"
title='"Your Next Client? The CEO&#39;s Son" on Advertising Age'
className="[--primary:#973999] dark:[--primary:#db60dd]"
>
outlets
</Link>
.
, an AI-powered bedtime story generator.
</p>
<p className="mt-3 mb-0 text-base leading-relaxed md:text-[0.975rem]">
<p className="mt-2 mb-0 text-sm leading-normal">
You can find my work on{" "}
<Link href="https://github.com/jakejarvis" rel="me" className="[--primary:#8d4eff] dark:[--primary:#a379f0]">
<Link href="https://github.com/jakejarvis" rel="me">
GitHub
</Link>{" "}
and{" "}
<Link
href="https://www.linkedin.com/in/jakejarvis/"
rel="me"
className="[--primary:#0073b1] dark:[--primary:#3b9dd2]"
>
<Link href="https://www.linkedin.com/in/jakejarvis/" rel="me">
LinkedIn
</Link>
. I&rsquo;m always available to connect over{" "}
<Link
href="/contact"
prefetch={false}
title="Send an email"
className="[--primary:#de0c0c] dark:[--primary:#ff5050]"
>
<Link href="/contact" prefetch={false} title="Send an email">
email
</Link>{" "}
<sup className="mr-0.5 text-[0.6rem]">
<sup className="">
<Link
href="https://jrvs.io/pgp"
rel="pgpkey"
title="3BC6 E577 6BF3 79D3 6F67 1480 2B0C 9CF2 51E6 9A39"
className="space-x-0.5 px-0.5 text-nowrap [--primary:var(--muted-foreground)] hover:no-underline"
title="Download my PGP key"
className="not-prose text-muted-foreground hover:text-primary space-x-1 px-0.5 text-nowrap no-underline hover:no-underline"
>
<LockIcon className="inline size-3 align-text-top" />{" "}
<code className="tracking-wider text-wrap [word-spacing:-3px]">2B0C 9CF2 51E6 9A39</code>
<LockIcon className="inline size-2.5" aria-hidden="true" />
<code className="text-[9px] leading-none tracking-wider text-wrap [word-spacing:-3px]">
2B0C 9CF2 51E6 9A39
</code>
</Link>
</sup>
,{" "}
<Link href="https://bsky.app/profile/jarv.is" rel="me" className="[--primary:#0085ff] dark:[--primary:#208bfe]">
Bluesky
</Link>
, or{" "}
<Link href="https://fediverse.jarv.is/@jake" rel="me" className="[--primary:#6d6eff] dark:[--primary:#7b87ff]">
Mastodon
</Link>{" "}
as well!
</sup>{" "}
as well.
</p>
</>
</div>
);
};

View File

@@ -11,6 +11,7 @@ import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import { createMetadata } from "@/lib/metadata";
import { getContributions, getRepos } from "./github";
import Button from "@/components/ui/button";
export const metadata = createMetadata({
title: "Projects",
@@ -122,9 +123,14 @@ const Page = async () => {
)}
<p className="mt-6 mb-0 text-center text-base font-medium">
<Link href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`} className="hover:no-underline">
View more on <GitHubIcon className="fill-foreground/80 mx-0.5 inline size-5 align-text-top" /> GitHub.
</Link>
<Button variant="secondary" asChild>
<Link
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}?tab=repositories&type=source&sort=stargazers`}
>
<GitHubIcon />
<span className="leading-none">Show All</span>
</Link>
</Button>
</p>
</>
);

View File

@@ -1,79 +1,51 @@
import { CodeIcon, TerminalIcon } from "lucide-react";
import { codeToHtml } from "shiki";
import { cacheLife } from "next/cache";
import CopyButton from "@/components/copy-button";
import { cn } from "@/lib/utils";
import reactToText from "react-to-text";
import { codeToHtml } from "shiki";
interface CodeBlockProps extends React.ComponentProps<"pre"> {
showLineNumbers?: boolean;
showCopyButton?: boolean;
}
const renderHighlightedCode = async (codeString: string, lang: string) => {
const renderCode = async (code: string, lang: string): Promise<string> => {
"use cache";
cacheLife("max");
const html = await codeToHtml(codeString, {
return codeToHtml(code, {
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
themes: { light: "github-light", dark: "github-dark" },
});
return html;
};
const CodeBlock = async (props: CodeBlockProps) => {
const { showLineNumbers = false, showCopyButton = true, children, className } = props;
// escape hatch if this code wasn't meant to be highlighted
const CodeBlock = async ({ children, className, showLineNumbers = true, ...props }: CodeBlockProps) => {
// Escape hatch for non-code pre blocks
if (!children || typeof children !== "object" || !("props" in children)) {
return <pre {...props}>{children}</pre>;
return (
<pre className={className} {...props}>
{children}
</pre>
);
}
const codeProps = children.props as React.ComponentProps<"code">;
const codeString = reactToText(codeProps.children).trim();
const lang = codeProps.className?.split("language-")[1] ?? "text";
// the language set in the markdown is passed as a className
const lang = codeProps.className?.split("language-")[1] ?? "";
const html = await renderHighlightedCode(codeString, lang);
const html = await renderCode(codeString, lang);
return (
<div className={cn("bg-muted/35 relative isolate rounded-lg border-2 font-mono shadow", className)}>
<div className="group not-prose relative">
<CopyButton
value={codeString}
className="absolute top-2 right-2 z-10 opacity-0 transition-opacity group-hover:opacity-100"
/>
<div
className={cn(
"grid max-h-[500px] w-full overflow-x-auto overscroll-x-none p-4 **:bg-transparent! data-language:pt-10 md:max-h-[650px] dark:**:text-[var(--shiki-dark)]! [&_pre]:whitespace-normal",
"[&_.line]:inline-block [&_.line]:min-w-full [&_.line]:py-1 [&_.line]:leading-none [&_.line]:whitespace-pre [&_.line]:after:hidden",
"data-line-numbers:[&_.line]:before:text-muted-foreground data-line-numbers:[counter-reset:line] data-line-numbers:[&_.line]:[counter-increment:line] data-line-numbers:[&_.line]:before:mr-5 data-line-numbers:[&_.line]:before:inline-block data-line-numbers:[&_.line]:before:w-5 data-line-numbers:[&_.line]:before:text-right data-line-numbers:[&_.line]:before:content-[counter(line)]"
)}
data-language={lang || undefined}
data-slot="code-block"
data-lang={lang}
data-line-numbers={showLineNumbers || undefined}
className={className}
dangerouslySetInnerHTML={{ __html: html }}
/>
{lang && (
<span className="[&_svg]:stroke-primary/90 text-foreground/75 bg-muted/40 absolute top-0 left-0 z-10 flex items-center gap-[8px] rounded-tl-md rounded-br-lg border-r-2 border-b-2 px-[10px] py-[5px] font-mono text-xs font-medium tracking-wide uppercase backdrop-blur-sm select-none [&_svg]:size-[14px] [&_svg]:shrink-0">
{["sh", "bash", "zsh", "shell"].includes(lang) ? (
<>
<TerminalIcon />
<span>Shell</span>
</>
) : (
<>
<CodeIcon />
<span>{lang}</span>
</>
)}
</span>
)}
{showCopyButton && (
<CopyButton
source={codeString}
className="text-foreground/75 hover:text-primary bg-muted/40 absolute top-0 right-0 z-10 size-10 rounded-tr-md rounded-bl-lg border-b-2 border-l-2 p-0 backdrop-blur-sm select-none [&_svg]:my-auto [&_svg]:inline-block [&_svg]:size-4.5 [&_svg]:align-text-bottom"
/>
)}
</div>
);
};

View File

@@ -1,59 +1,65 @@
"use client";
import { useState, useEffect } from "react";
import * as React from "react";
import copy from "copy-to-clipboard";
import { ClipboardIcon, CheckIcon } from "lucide-react";
import { CheckIcon, CopyIcon } from "lucide-react";
import Button from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
const CopyButton = ({
source,
timeout = 2000,
function CopyButton({
value,
className,
...rest
}: React.ComponentProps<"button"> & {
source: string;
timeout?: number;
}) => {
const [copied, setCopied] = useState(false);
variant = "ghost",
tooltip = "Copy to Clipboard",
...props
}: React.ComponentProps<typeof Button> & {
value: string;
tooltip?: string;
}) {
const [hasCopied, setHasCopied] = React.useState(false);
const timeoutRef = React.useRef<NodeJS.Timeout | undefined>(undefined);
const handleCopy: React.MouseEventHandler<React.ComponentRef<"button">> = (e) => {
// prevent unintentional double-clicks by unfocusing button
e.currentTarget.blur();
React.useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
// send plaintext to the clipboard
const didCopy = copy(source);
const handleCopy = () => {
copy(value);
setHasCopied(true);
// indicate success
setCopied(didCopy);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setHasCopied(false), 2000);
};
useEffect(() => {
if (!copied) {
return;
}
// reset to original icon after given ms (defaults to 2 seconds)
const reset = setTimeout(() => {
setCopied(false);
}, timeout);
// cancel timeout to avoid memory leaks if unmounted in the middle of this
return () => {
clearTimeout(reset);
};
}, [timeout, copied]);
return (
<button
onClick={handleCopy}
disabled={copied}
className={cn("cursor-pointer disabled:cursor-default", className)}
{...rest}
>
{copied ? <CheckIcon className="stroke-success" /> : <ClipboardIcon />}
<span className="sr-only">{copied ? "Copied" : "Copy to clipboard"}</span>
</button>
<Tooltip>
<TooltipTrigger asChild>
<Button
data-slot="copy-button"
data-copied={hasCopied}
size="icon"
variant={variant}
className={cn(
"bg-code absolute top-3 right-2 z-10 size-7 hover:opacity-100 focus-visible:opacity-100",
className
)}
onClick={handleCopy}
aria-label={hasCopied ? "Copied" : tooltip}
{...props}
>
{hasCopied ? <CheckIcon aria-hidden="true" /> : <CopyIcon aria-hidden="true" />}
</Button>
</TooltipTrigger>
<TooltipContent>{hasCopied ? "Copied" : tooltip}</TooltipContent>
</Tooltip>
);
};
}
export default CopyButton;

View File

@@ -1,54 +1,26 @@
import { env } from "@/lib/env";
import { HeartIcon } from "lucide-react";
import Link from "@/components/link";
import { NextjsIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import siteConfig from "@/lib/config/site";
const Footer = ({ className, ...rest }: React.ComponentProps<"footer">) => {
const Footer = () => {
return (
<footer
className={cn("text-foreground/85 text-[0.8rem] leading-loose md:flex md:flex-row md:justify-between", className)}
{...rest}
>
<div>
Content{" "}
<Link href="/license" prefetch={false} className="text-foreground/85 hover:no-underline">
licensed under {siteConfig.license}
</Link>
,{" "}
<Link
href="/previously"
prefetch={false}
title="Previously on..."
className="text-foreground/85 hover:no-underline"
>
{siteConfig.copyrightYearStart}
</Link>{" "}
2025.
</div>
<div>
Made with{" "}
<HeartIcon className="animate-heartbeat stroke-destructive fill-destructive mx-px inline size-4 align-text-top" />{" "}
and{" "}
<Link
href="https://nextjs.org/"
title="Powered by Next.js"
aria-label="Next.js"
className="text-foreground/85 hover:text-foreground/60 hover:no-underline"
>
<NextjsIcon className="mx-px inline size-4 align-text-top" />
</Link>
.{" "}
<Link
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
title="View Source on GitHub"
className="border-muted-foreground text-foreground/85 hover:border-muted-foreground/60 border-b-1 pb-0.5 hover:no-underline"
>
View source.
</Link>
</div>
<footer className="text-muted-foreground mt-8 w-full py-6 text-center text-[13px] leading-loose">
Content{" "}
<Link href="/license" prefetch={false}>
licensed under {siteConfig.license}
</Link>
,{" "}
<Link href="/previously" prefetch={false} title="Previously on...">
{siteConfig.copyrightYearStart}
</Link>{" "}
2026.{" "}
<Link
href={`https://github.com/${env.NEXT_PUBLIC_GITHUB_REPO}`}
title="View Source on GitHub"
className="font-medium underline underline-offset-4"
>
View source.
</Link>
</footer>
);
};

View File

@@ -1,36 +1,90 @@
"use client";
import { useState, useEffect } from "react";
import { useTheme } from "next-themes";
import Image from "next/image";
import Link from "@/components/link";
import Button from "@/components/ui/button";
import Separator from "@/components/ui/separator";
import Menu from "@/components/layout/menu";
import { GitHubIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { MoonIcon, SunIcon } from "lucide-react";
import avatarImg from "@/app/avatar.jpg";
const Header = ({ className, ...rest }: React.ComponentProps<"header">) => {
return (
<header className={cn("flex items-center justify-between", className)} {...rest}>
<Link
href="/"
rel="author"
aria-label={siteConfig.name}
className="hover:text-primary text-foreground/85 flex shrink-0 items-center hover:no-underline"
>
<Image
src={avatarImg}
alt={`Photo of ${siteConfig.name}`}
className="border-ring/40 size-[64px] rounded-full border-2 md:size-[48px] md:border-1"
width={64}
height={64}
quality={50}
priority
/>
<span className="mx-3 text-xl leading-none font-medium tracking-[0.01rem] max-md:sr-only">
{siteConfig.name}
</span>
</Link>
const Header = ({ className }: { className?: string }) => {
const [isScrolled, setIsScrolled] = useState(false);
const { theme, setTheme } = useTheme();
<Menu className="w-full max-w-64 sm:max-w-96 md:max-w-none" />
</header>
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 10);
};
// Check initial scroll position
handleScroll();
window.addEventListener("scroll", handleScroll, { passive: true });
return () => window.removeEventListener("scroll", handleScroll);
}, []);
return (
<div
data-scrolled={isScrolled}
className={cn(
"sticky top-0 z-50 w-full",
"motion-safe:transition-[background-color,backdrop-filter,border-color] motion-safe:duration-200",
"bg-background/0 backdrop-blur-none",
"data-[scrolled=true]:bg-background/80 data-[scrolled=true]:backdrop-blur-md",
"data-[scrolled=true]:border-border/50 data-[scrolled=true]:border-b",
className
)}
>
<header className="mx-auto flex w-full max-w-4xl items-center justify-between px-5 py-4">
<div className="flex items-center gap-3">
<Link
href="/"
rel="author"
aria-label={siteConfig.name}
className="hover:text-foreground/85 flex shrink-0 items-center gap-2.5 pr-2 hover:no-underline"
>
<Image
src={avatarImg}
alt={`Photo of ${siteConfig.name}`}
className="border-ring/30 size-[40px] rounded-full border md:size-[32px]"
width={40}
height={40}
quality={75}
priority
/>
<span className="text-[17px] font-medium whitespace-nowrap max-md:sr-only">{siteConfig.name}</span>
</Link>
<Separator orientation="vertical" className="!h-6" />
<Menu />
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" aria-label="Open GitHub profile" asChild>
<Link href={`https://github.com/${authorConfig.social.github}`}>
<GitHubIcon />
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label="Toggle theme"
className="group"
>
<SunIcon className="group-hover:stroke-orange-600 dark:hidden" aria-hidden="true" />
<MoonIcon className="not-dark:hidden group-hover:stroke-yellow-400" aria-hidden="true" />
</Button>
</div>
</header>
</div>
);
};

View File

@@ -1,48 +0,0 @@
import Link from "@/components/link";
import { cn } from "@/lib/utils";
const MenuItem = ({
text,
href,
icon,
current,
className,
...rest
}: React.ComponentProps<"div"> & {
text?: string;
href?: `/${string}`;
icon: React.ReactNode;
current?: boolean;
}) => {
const item = (
<div
className={cn(
"[&_svg]:stroke-foreground/85 inline-flex items-center [&_svg]:size-7 [&_svg]:md:size-5",
className
)}
{...rest}
>
{icon}
{text && <span className="ml-3 text-sm leading-none font-medium tracking-wide max-md:sr-only">{text}</span>}
</div>
);
// allow both navigational links and/or other interactive react components (e.g. the theme toggle)
if (href) {
return (
<Link
href={href}
prefetch={false}
aria-label={text}
data-current={current || undefined}
className="text-foreground/85 hover:border-b-ring/80 data-current:border-b-primary/60 inline-flex items-center hover:no-underline"
>
{item}
</Link>
);
}
return item;
};
export default MenuItem;

View File

@@ -1,58 +1,38 @@
"use client";
import { useSelectedLayoutSegment } from "next/navigation";
import MenuItem from "@/components/layout/menu-item";
import ThemeToggle from "@/components/theme/theme-toggle";
import { cn } from "@/lib/utils";
import { HomeIcon, PencilLineIcon, CodeXmlIcon, MailIcon } from "lucide-react";
import Button from "@/components/ui/button";
import Link from "@/components/link";
const menuItems: React.ComponentProps<typeof MenuItem>[] = [
{
text: "Home",
href: "/",
icon: <HomeIcon />,
},
const menuItems = [
{
text: "Notes",
href: "/notes",
icon: <PencilLineIcon />,
},
{
text: "Projects",
href: "/projects",
icon: <CodeXmlIcon />,
},
{
text: "Contact",
href: "/contact",
icon: <MailIcon />,
},
{
icon: <ThemeToggle />,
},
];
] as const;
const Menu = ({ className, ...rest }: React.ComponentProps<"div">) => {
const Menu = () => {
const segment = useSelectedLayoutSegment() || "";
return (
<div
className={cn(
"flex max-w-2/3 flex-row items-center justify-between md:max-w-none md:justify-end md:gap-4",
className
)}
{...rest}
>
<div className="flex items-center gap-2">
{menuItems.map((item, index) => {
const isCurrent = item.href?.split("/")[1] === segment;
return (
<div
className="inline-flex items-center last:-mr-2.5 max-sm:first:hidden **:[a,button]:border-y-3 **:[a,button]:border-y-transparent **:[a,button]:p-2.5"
key={index}
>
<MenuItem {...item} current={isCurrent} />
</div>
<Button key={index} variant="ghost" size="sm" asChild>
<Link href={item.href} prefetch={false} aria-label={item.text} data-current={isCurrent}>
{item.text}
</Link>
</Button>
);
})}
</div>

View File

@@ -10,7 +10,10 @@ const PageTitle = ({
canonical: string;
}) => {
return (
<h1 className={cn("mt-0 mb-6 text-left text-3xl font-medium tracking-[-0.015em] lowercase", className)} {...rest}>
<h1
className={cn("mt-0 mb-6 text-left text-4xl font-semibold tracking-tight lowercase sm:text-3xl", className)}
{...rest}
>
<Link
href={canonical}
className="before:text-muted-foreground before:-mr-0.5 before:tracking-widest before:content-['\002E\002F'] hover:no-underline"

View File

@@ -1,7 +1,6 @@
import NextLink from "next/link";
import { cn } from "@/lib/utils";
const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<typeof NextLink>) => {
const Link = ({ href, rel, target, ...rest }: React.ComponentProps<typeof NextLink>) => {
// This component auto-detects whether or not this link should open in the same window (the default for internal
// links) or a new tab (the default for external links). Defaults can be overridden with `target="_blank"`.
const isExternal = typeof href === "string" && !["/", "#"].includes(href[0]);
@@ -10,10 +9,6 @@ const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<ty
href,
target: target || (isExternal ? "_blank" : undefined),
rel: `${rel ? `${rel} ` : ""}${target === "_blank" || isExternal ? "noopener noreferrer" : ""}`.trim() || undefined,
className: cn(
"text-primary decoration-primary/40 no-underline decoration-2 underline-offset-4 hover:underline",
className
),
...rest,
};

View File

@@ -1,19 +1,11 @@
"use client";
import { ThemeProvider } from "@/components/theme/theme-context";
import { ProgressProvider } from "@bprogress/next/app";
import { ThemeProvider } from "next-themes";
const Providers = ({ children }: { children: React.ReactNode }) => {
return (
<ThemeProvider>
<ProgressProvider
height="calc(var(--spacing) * 1)"
color="var(--primary)"
options={{ showSpinner: false }}
shallowRouting
>
{children}
</ProgressProvider>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
{children}
</ThemeProvider>
);
};

View File

@@ -1,48 +0,0 @@
"use client";
import { createContext, useEffect } from "react";
import { useLocalStorage, useMedia } from "react-use";
export const ThemeContext = createContext<{
/**
* If the user's theme preference is unset, this returns whether the system preference resolved to "light" or "dark".
* If the user's theme preference is set, the preference is returned instead, regardless of their system's theme.
*/
theme: string;
/** Update the theme manually and save to local storage. */
setTheme: (theme: string) => void;
}>({
theme: "",
// eslint-disable-next-line @typescript-eslint/no-unused-vars
setTheme: (_) => {},
});
// provider used once in _app.tsx to wrap entire app
export const ThemeProvider = ({ children }: React.PropsWithChildren) => {
// keep track of if/when the user has set their theme *on this site*
const [preferredTheme, setPreferredTheme] = useLocalStorage<string>("theme", undefined, { raw: true });
// hook into system `prefers-color-scheme` setting
// https://web.dev/prefers-color-scheme/#the-prefers-color-scheme-media-query
const isSystemDark = useMedia("(prefers-color-scheme: dark)", false);
// Derive system theme directly from media query to avoid setState in effect
const systemTheme = isSystemDark ? "dark" : "light";
// actual DOM updates must be done in useEffect
useEffect(() => {
// only "light" and "dark" are valid themes
const resolvedTheme = preferredTheme && ["light", "dark"].includes(preferredTheme) ? preferredTheme : systemTheme;
// this is what actually changes the CSS variables
document.documentElement.dataset.theme = resolvedTheme;
// less important, but tells the browser how to render built-in elements like forms, scrollbars, etc.
document.documentElement.style?.setProperty("color-scheme", resolvedTheme);
}, [preferredTheme, systemTheme]);
const providerValues = {
theme: preferredTheme ?? systemTheme,
setTheme: setPreferredTheme,
};
return <ThemeContext.Provider value={providerValues}>{children}</ThemeContext.Provider>;
};

View File

@@ -1,12 +0,0 @@
// loaded in <head> by layout.tsx to avoid blinding flash of unstyled content (FOUC). irrelevant after the first render
// when <ThemeProvider /> takes over.
// unminified JS: https://gist.github.com/jakejarvis/79b0ec8506bc843023546d0d29861bf0
export const ThemeScript = () => (
<script
id="restore-theme"
dangerouslySetInnerHTML={{
__html:
"(()=>{try{const e=document.documentElement,t='undefined'!=typeof Storage?window.localStorage.getItem('theme'):null,a=(t&&'dark'===t)??window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';e.dataset.theme=a,e.style.colorScheme=a}catch(e){}})()",
}}
/>
);

View File

@@ -1,25 +0,0 @@
"use client";
import { useContext } from "react";
import { MoonIcon, SunIcon } from "lucide-react";
import { ThemeContext } from "@/components/theme/theme-context";
import { cn } from "@/lib/utils";
const ThemeToggle = ({ className, ...rest }: React.ComponentProps<"button">) => {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
aria-label="Toggle theme"
className={cn("hover:[&_svg]:stroke-warning block cursor-pointer bg-transparent", className)}
{...rest}
>
<SunIcon className="dark:hidden" />
<MoonIcon className="not-dark:hidden" />
<span className="sr-only">Toggle theme</span>
</button>
);
};
export default ThemeToggle;

View File

@@ -1,9 +1,8 @@
import NextImage from "next/image";
import Link from "@/components/link";
import CodeBlock from "@/components/code-block";
import HeadingAnchor from "@/components/heading-anchor";
import Video from "@/components/video";
import ImageDiff from "./components/image-diff";
import ImageDiff from "@/components/image-diff";
import Tweet from "@/components/third-party/tweet";
import YouTube from "@/components/third-party/youtube";
import Gist from "@/components/third-party/gist";
@@ -15,106 +14,30 @@ export const useMDXComponents = (components: MDXComponents): MDXComponents => {
return {
...components,
a: Link,
pre: ({ className, ...rest }) => <CodeBlock className={cn("my-5 w-full font-mono text-sm", className)} {...rest} />,
code: ({ className, ...rest }) => (
// only applies to inline code, *not* highlighted code blocks!
<code
className={cn("bg-muted relative rounded-sm px-[0.3rem] py-[0.2rem] text-sm font-medium", className)}
{...rest}
/>
),
img: ({ src, className, ...rest }) => (
<NextImage
src={src}
width={typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined} // => var(--container-4xl)
className={cn(
"mx-auto my-8 block h-auto max-w-full rounded-md",
"[&+em]:text-muted-foreground [&+em]:-mt-4 [&+em]:block [&+em]:text-center [&+em]:text-[0.875em] [&+em]:leading-normal [&+em]:font-medium [&+em]:not-italic",
className
)}
{...rest}
/>
),
figure: ({ className, ...rest }) => <figure className={cn("my-8 *:my-0", className)} {...rest} />,
figcaption: ({ className, ...rest }) => (
<figcaption className={cn("text-muted-foreground mt-3.5 text-[0.875em] leading-snug", className)} {...rest} />
),
blockquote: ({ className, ...rest }) => (
<blockquote className={cn("text-muted-foreground mt-6 border-l-4 pl-4", className)} {...rest} />
),
h1: ({ className, id, children, ...rest }) => (
<h1
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-3xl leading-snug font-extrabold md:text-4xl [&_strong]:font-black [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h1>
),
h2: ({ className, id, children, ...rest }) => (
<h2
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-xl leading-snug font-bold first:mt-0 md:text-2xl [&_code]:text-[0.875em] [&_strong]:font-extrabold [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h2>
),
h3: ({ className, id, children, ...rest }) => (
<h3
className={cn(
"group mt-6 mb-4 scroll-mt-4 text-lg leading-relaxed font-semibold md:text-xl [&_code]:text-[0.9em] [&_strong]:font-bold [&+*]:mt-0",
className
)}
id={id}
tabIndex={-1}
{...rest}
>
{children}
{id && <HeadingAnchor id={id} title={children} className="opacity-0 group-hover:opacity-100 max-md:hidden" />}
</h3>
),
h4: ({ className, ...rest }) => (
<h4
className={cn(
"mt-6 mb-2 scroll-mt-4 text-base leading-normal font-semibold [&_strong]:font-bold [&+*]:mt-0",
className
)}
{...rest}
/>
),
h5: ({ className, ...rest }) => (
<h5 className={cn("mt-6 mb-2 scroll-mt-4 text-base leading-normal font-medium", className)} {...rest} />
),
h6: ({ className, ...rest }) => (
<h6 className={cn("mt-6 mb-2 scroll-mt-4 text-sm leading-normal font-normal", className)} {...rest} />
),
ul: ({ className, ...rest }) => <ul className={cn("my-5 list-disc pl-7 [&>li]:pl-1.5", className)} {...rest} />,
ol: ({ className, ...rest }) => <ol className={cn("my-5 list-decimal pl-7 [&>li]:pl-1.5", className)} {...rest} />,
li: ({ className, ...rest }) => (
<li
className={cn(
"[&::marker]:text-muted-foreground my-0.5 [&::marker]:font-normal [&>ol]:my-1 [&>ul]:my-1",
className
)}
{...rest}
/>
),
hr: ({ className, ...rest }) => (
<hr className={cn("mx-auto my-6 w-11/12 border-t-2 [&+*]:mt-0", className)} {...rest} />
),
pre: CodeBlock,
img: ({ src, className, ...rest }) => {
const imageWidth = typeof src === "object" && "width" in src && src.width > 896 ? 896 : undefined;
const imageHeight =
imageWidth && typeof src === "object" && "width" in src && "height" in src
? Math.round((src.height / src.width) * imageWidth)
: undefined;
// react components and embeds:
return (
<NextImage
src={src}
width={imageWidth}
height={imageHeight}
className={cn(
"mx-auto my-8 block h-auto max-w-full rounded-sm",
"[&+em]:text-muted-foreground [&+em]:-mt-4 [&+em]:block [&+em]:text-center [&+em]:text-[0.875em] [&+em]:leading-normal [&+em]:font-medium [&+em]:not-italic",
className
)}
{...rest}
/>
);
},
// React components and embeds:
Video,
ImageDiff,
Tweet,

View File

@@ -32,6 +32,7 @@ const nextConfig = {
},
productionBrowserSourceMaps: true,
experimental: {
viewTransition: true,
serverActions: {
// fix CSRF errors from tor reverse proxy
allowedOrigins: [
@@ -156,7 +157,12 @@ const nextPlugins: Array<
rehypePlugins: [
"rehype-unwrap-images",
"rehype-slug",
["rehype-wrapper", { className: "generated" }],
[
"rehype-wrapper",
{
className: "prose prose-sm prose-neutral dark:prose-invert max-w-none",
},
],
"rehype-mdx-code-props",
"rehype-mdx-import-media",
],

View File

@@ -20,13 +20,12 @@
"prepare": "test -d node_modules/husky && husky || echo \"skipping husky\""
},
"dependencies": {
"@bprogress/next": "^3.2.12",
"@date-fns/tz": "^1.4.1",
"@date-fns/utc": "^2.1.1",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.0.2",
"@next/mdx": "16.1.0",
"@next/mdx": "16.1.6",
"@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.1",
"@radix-ui/react-alert-dialog": "^1.1.15",
@@ -44,24 +43,25 @@
"@t3-oss/env-nextjs": "^0.13.10",
"@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1",
"better-auth": "^1.4.7",
"better-auth": "^1.4.17",
"botid": "^1.5.10",
"cheerio": "^1.1.2",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"fast-glob": "^3.3.3",
"feed": "^5.1.0",
"feed": "^5.2.0",
"geist": "^1.5.1",
"html-entities": "^2.6.0",
"lucide-react": "0.562.0",
"next": "16.1.0",
"react": "19.2.3",
"react-activity-calendar": "^3.0.1",
"lucide-react": "0.563.0",
"next": "16.1.6",
"next-themes": "^0.4.6",
"react": "19.2.4",
"react-activity-calendar": "^3.0.5",
"react-countup": "^6.5.3",
"react-dom": "19.2.3",
"react-dom": "19.2.4",
"react-lite-youtube-embed": "^3.3.3",
"react-markdown": "^10.1.0",
"react-schemaorg": "^2.0.0",
@@ -85,45 +85,47 @@
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.6.0",
"resend": "^6.9.1",
"server-only": "0.0.1",
"shiki": "^3.20.0",
"shiki": "^3.21.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5",
"zod": "^4.2.1"
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@jakejarvis/eslint-config": "^4.0.7",
"@tailwindcss/postcss": "^4.1.18",
"@tailwindcss/typography": "^0.5.19",
"@types/hast": "^3.0.4",
"@types/mdx": "^2.0.13",
"@types/node": "^25.0.3",
"@types/react": "19.2.7",
"@types/node": "^25.0.10",
"@types/react": "19.2.10",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.1.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.8",
"eslint": "^9.39.2",
"eslint-config-next": "16.1.0",
"eslint-config-next": "16.1.6",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-mdx": "^3.6.2",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"prettier": "^3.7.4",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"schema-dts": "^1.1.5",
"typescript": "5.9.3"

2237
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff