1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 19:15:30 -04:00

chore: Next.js 15 → 16 (#2503)

This commit is contained in:
2025-11-22 17:11:42 -05:00
committed by GitHub
parent 19960ca2b0
commit 8a09aa918c
27 changed files with 2457 additions and 2304 deletions
-15
View File
@@ -1,15 +0,0 @@
/** @type {import("npm-check-updates").RunOptions} */
const config = {
deep: true,
// workspaces: true,
// root: true,
target: (dependencyName) => {
if (/next$|(^@next\/.*$)/.test(dependencyName)) {
return "@canary";
}
return "latest";
},
};
module.exports = config;
+1 -1
View File
@@ -1 +1 @@
22.19.0
22.21.1
+1 -7
View File
@@ -1,11 +1,5 @@
import { NextResponse } from "next/server";
import { unstable_cache as cache } from "next/cache";
import { getViewCounts as _getViewCounts } from "@/lib/views";
const getViewCounts = cache(_getViewCounts, undefined, {
revalidate: 300, // 5 minutes
tags: ["hits"],
});
import { getViewCounts } from "@/lib/views";
export const GET = async (): Promise<
NextResponse<{
-2
View File
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed";
export const dynamic = "force-static";
export const GET = async () => {
const feed = await buildFeed();
-2
View File
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed";
export const dynamic = "force-static";
export const GET = async () => {
const feed = await buildFeed();
-3
View File
@@ -13,9 +13,6 @@ export const size = {
height: 630,
};
// generate and cache these images at build-time for each slug, since doing this on-demand is mega slow...
export const dynamicParams = false;
export const generateStaticParams = async () => {
const slugs = await getSlugs();
+17 -15
View File
@@ -1,5 +1,6 @@
import { env } from "@/lib/env";
import { Suspense } from "react";
import { cacheLife } from "next/cache";
import { JsonLd } from "react-schemaorg";
import { formatDate, formatDateISO } from "@/lib/date";
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon, MessagesSquareIcon } from "lucide-react";
@@ -16,12 +17,6 @@ import { getCommentCounts } from "@/lib/server/comments";
import type { Metadata } from "next";
import type { BlogPosting } from "schema-dts";
// https://nextjs.org/docs/app/api-reference/functions/generate-static-params#disable-rendering-for-unspecified-paths
export const dynamicParams = false;
// https://nextjs.org/docs/app/building-your-application/rendering/partial-prerendering#using-partial-prerendering
export const experimental_ppr = true;
export const generateStaticParams = async () => {
const slugs = await getSlugs();
@@ -52,10 +47,23 @@ export const generateMetadata = async ({ params }: { params: Promise<{ slug: str
});
};
// Cached helper to format dates - needed for Cache Components compatibility
const getFormattedDates = async (date: string) => {
"use cache";
cacheLife("max");
return {
dateISO: formatDateISO(date),
dateTitle: formatDate(date, "MMM d, y, h:mm a O"),
dateDisplay: formatDate(date, "MMMM d, y"),
};
};
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 formattedDates = await getFormattedDates(frontmatter!.date);
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`);
@@ -92,8 +100,8 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
className={"text-foreground/70 flex flex-nowrap items-center gap-x-2 whitespace-nowrap hover:no-underline"}
>
<CalendarDaysIcon className="inline size-4 shrink-0" />
<time dateTime={formatDateISO(frontmatter!.date)} title={formatDate(frontmatter!.date, "MMM d, y, h:mm a O")}>
{formatDate(frontmatter!.date, "MMMM d, y")}
<time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle}>
{formattedDates.dateDisplay}
</time>
</Link>
@@ -133,13 +141,7 @@ const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
<div className="flex min-w-14 flex-nowrap items-center gap-x-2 whitespace-nowrap">
<EyeIcon className="inline size-4 shrink-0" />
<Suspense
// when this loads, the component will count up from zero to the actual number of hits, so we can simply
// show a zero here as a "loading indicator"
fallback={<span className="motion-safe:animate-pulse">0</span>}
>
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</Suspense>
<ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</div>
</div>
+76 -41
View File
@@ -1,4 +1,5 @@
import { env } from "@/lib/env";
import { cacheLife } from "next/cache";
import { EyeIcon, MessagesSquareIcon } from "lucide-react";
import Link from "@/components/link";
import { getFrontMatter, POSTS_DIR, type FrontMatter } from "@/lib/posts";
@@ -8,26 +9,83 @@ import authorConfig from "@/lib/config/author";
import { getViewCounts } from "@/lib/views";
import { getCommentCounts } from "@/lib/server/comments";
export const revalidate = 300; // 5 minutes
export const metadata = createMetadata({
title: "Notes",
description: `Recent posts by ${authorConfig.name}.`,
canonical: `/${POSTS_DIR}`,
});
const Page = async () => {
// parse the year of each post and group them together
// Hoist number formatter to avoid re-creating on every render
const numberFormatter = new Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE);
// Non-async component for displaying stats (receives data as props)
const PostStats = ({ views, comments, slug }: { views: number; comments: number; slug: string }) => {
return (
<>
{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>
</span>
)}
{comments > 0 && (
<Link
href={`/${POSTS_DIR}/${slug}#comments`}
title={`${numberFormatter.format(comments)} ${comments === 1 ? "comment" : "comments"}`}
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>
</span>
</Link>
)}
</>
);
};
// Cached helper to format dates for posts - needed for Cache Components compatibility
const getFormattedPostDates = async (posts: FrontMatter[]) => {
"use cache";
cacheLife("max");
return posts.map((post) => {
const year = new Date(post.date).getUTCFullYear();
return {
...post,
year,
dateISO: formatDateISO(post.date),
dateTitle: formatDate(post.date, "MMM d, y, h:mm a O"),
dateDisplay: formatDate(post.date, "MMM d"),
};
});
};
// Async component that fetches all stats once and renders the full page
const PostsList = async () => {
// Fetch posts and stats in parallel (only once per page load)
// These functions are cached with "use cache", so they can complete during prerendering
const [posts, views, comments] = await Promise.all([getFrontMatter(), getViewCounts(), getCommentCounts()]);
// Format dates in a cached function to avoid date-fns using new Date() during render
const formattedPosts = await getFormattedPostDates(posts);
const postsByYear: {
[year: string]: (FrontMatter & { views: number; comments: number })[];
[year: string]: (FrontMatter & {
year: number;
dateISO: string;
dateTitle: string;
dateDisplay: string;
views: number;
comments: number;
})[];
} = {};
posts.forEach((post) => {
const year = new Date(post.date).getUTCFullYear();
(postsByYear[year] || (postsByYear[year] = [])).push({
formattedPosts.forEach((post) => {
(postsByYear[post.year] || (postsByYear[post.year] = [])).push({
...post,
// Include pre-fetched stats
views: views[`${POSTS_DIR}/${post.slug}`] || 0,
comments: comments[`${POSTS_DIR}/${post.slug}`] || 0,
});
@@ -42,43 +100,18 @@ const Page = async () => {
{year}
</h2>
<ul className="space-y-4">
{posts.map(({ slug, date, title, htmlTitle, views, comments }) => (
{posts.map(({ slug, dateISO, dateTitle, dateDisplay, title, htmlTitle, views, comments }) => (
<li className="flex text-base leading-relaxed" key={slug}>
<span className="text-muted-foreground w-18 shrink-0 md:w-22">
<time dateTime={formatDateISO(date)} title={formatDate(date, "MMM d, y, h:mm a O")}>
{formatDate(date, "MMM d")}
<time dateTime={dateISO} title={dateTitle}>
{dateDisplay}
</time>
</span>
<div className="space-x-2.5">
<Link
dynamicOnHover
href={`/${POSTS_DIR}/${slug}`}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
/>
{/* htmlTitle is sanitized by rehypeSanitize in lib/posts.ts with strict allowlist: only code, em, strong tags */}
<Link href={`/${POSTS_DIR}/${slug}`} dangerouslySetInnerHTML={{ __html: htmlTitle || title }} />
{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">
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(views)}
</span>
</span>
)}
{comments > 0 && (
<Link
href={`/${POSTS_DIR}/${slug}#comments`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(comments)} ${comments === 1 ? "comment" : "comments"}`}
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">
{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(comments)}
</span>
</span>
</Link>
)}
<PostStats slug={slug} views={views} comments={comments} />
</div>
</li>
))}
@@ -88,9 +121,11 @@ const Page = async () => {
});
// grouped posts enter this component ordered chronologically -- we want reverse chronological
const reversed = sections.reverse();
return <>{sections.reverse()}</>;
};
return <>{reversed}</>;
const Page = async () => {
return <PostsList />;
};
export default Page;
+7 -19
View File
@@ -1,6 +1,7 @@
import "server-only";
import { env } from "@/lib/env";
import { cacheLife } from "next/cache";
import * as cheerio from "cheerio";
import { graphql } from "@octokit/graphql";
import type { Repository, User } from "@octokit/graphql-schema";
@@ -12,6 +13,9 @@ export const getContributions = async (): Promise<
level: number;
}>
> => {
"use cache";
cacheLife("minutes");
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
try {
const response = await fetch(`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`, {
@@ -19,11 +23,6 @@ export const getContributions = async (): Promise<
referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
"x-requested-with": "XMLHttpRequest",
},
cache: "force-cache",
next: {
revalidate: 900, // 15 minutes
tags: ["github-contributions"],
},
});
const $ = cheerio.load(await response.text());
@@ -78,6 +77,9 @@ export const getContributions = async (): Promise<
};
export const getRepos = async (): Promise<Repository[] | undefined> => {
"use cache";
cacheLife("minutes");
try {
// https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>(
@@ -118,20 +120,6 @@ export const getRepos = async (): Promise<Repository[] | undefined> => {
accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`,
},
request: {
// override fetch() to use next's extension to cache the response
// https://nextjs.org/docs/app/api-reference/functions/fetch#fetchurl-options
fetch: (url: string | URL | Request, options?: RequestInit) => {
return fetch(url, {
...options,
cache: "force-cache",
next: {
revalidate: 900, // 15 minutes
tags: ["github-repos"],
},
});
},
},
}
);
+61 -51
View File
@@ -42,9 +42,13 @@ const Page = async () => {
</h2>
<Suspense fallback={<p>Failed to generate activity calendar.</p>}>
<div className={cn("mx-auto mt-4 mb-8")}>
<ActivityCalendar data={contributions} noun="contribution" />
</div>
{contributions.length > 0 ? (
<div className={cn("mx-auto mt-4 mb-8")}>
<ActivityCalendar data={contributions} noun="contribution" />
</div>
) : (
<p className="text-muted-foreground my-4 text-center">Unable to load contribution data at this time.</p>
)}
</Suspense>
<h2 className="my-3.5 text-xl font-medium">
@@ -56,59 +60,65 @@ const Page = async () => {
</Link>
</h2>
<div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2">
{repos?.map((repo) => (
<div key={repo!.name} className="border-ring/30 h-fit space-y-1.5 rounded-2xl border-1 px-4 py-3 shadow-xs">
<Link href={repo!.url} className="inline-block text-base leading-relaxed font-semibold">
{repo!.name}
</Link>
{repos && repos.length > 0 ? (
<div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2">
{repos.map((repo) => (
<div key={repo!.name} className="border-ring/30 h-fit space-y-1.5 rounded-2xl border-1 px-4 py-3 shadow-xs">
<Link href={repo!.url} className="inline-block text-base leading-relaxed font-semibold">
{repo!.name}
</Link>
{repo!.description && <p className="text-foreground/85 text-sm leading-relaxed">{repo!.description}</p>}
{repo!.description && <p className="text-foreground/85 text-sm leading-relaxed">{repo!.description}</p>}
<div className="flex flex-wrap gap-x-4 text-[0.825rem] leading-loose whitespace-nowrap">
{repo!.primaryLanguage && (
<div className="text-muted-foreground inline-flex flex-nowrap items-center gap-2">
{repo!.primaryLanguage.color && (
<span
className="inline-block size-4 rounded-full bg-[var(--language-color)]"
style={{ ["--language-color" as string]: repo!.primaryLanguage.color }}
/>
)}
<span>{repo!.primaryLanguage.name}</span>
<div className="flex flex-wrap gap-x-4 text-[0.825rem] leading-loose whitespace-nowrap">
{repo!.primaryLanguage && (
<div className="text-muted-foreground inline-flex flex-nowrap items-center gap-2">
{repo!.primaryLanguage.color && (
<span
className="inline-block size-4 rounded-full bg-[var(--language-color)]"
style={{ ["--language-color" as string]: repo!.primaryLanguage.color }}
/>
)}
<span>{repo!.primaryLanguage.name}</span>
</div>
)}
{repo!.stargazerCount > 0 && (
<Link
href={`${repo!.url}/stargazers`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)} ${repo!.stargazerCount === 1 ? "star" : "stars"}`}
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
>
<StarIcon className="inline-block size-4 shrink-0" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)}</span>
</Link>
)}
{repo!.forkCount > 0 && (
<Link
href={`${repo!.url}/network/members`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)} ${repo!.forkCount === 1 ? "fork" : "forks"}`}
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
>
<GitForkIcon className="inline-block size-4" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)}</span>
</Link>
)}
<div className="text-muted-foreground whitespace-nowrap">
<Suspense fallback={<span>Updated recently</span>}>
<span>
Updated <RelativeTime date={repo!.pushedAt} />
</span>
</Suspense>
</div>
)}
{repo!.stargazerCount > 0 && (
<Link
href={`${repo!.url}/stargazers`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)} ${repo!.stargazerCount === 1 ? "star" : "stars"}`}
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
>
<StarIcon className="inline-block size-4 shrink-0" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.stargazerCount)}</span>
</Link>
)}
{repo!.forkCount > 0 && (
<Link
href={`${repo!.url}/network/members`}
title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)} ${repo!.forkCount === 1 ? "fork" : "forks"}`}
className="text-muted-foreground hover:text-primary inline-flex flex-nowrap items-center gap-2 hover:no-underline"
>
<GitForkIcon className="inline-block size-4" />
<span>{Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(repo!.forkCount)}</span>
</Link>
)}
<div className="text-muted-foreground whitespace-nowrap">
<span>
Updated <RelativeTime date={repo!.pushedAt} />
</span>
</div>
</div>
</div>
))}
</div>
))}
</div>
) : (
<p className="text-muted-foreground my-4 text-center">Unable to load repository data at this time.</p>
)}
<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">
+28 -24
View File
@@ -1,26 +1,36 @@
import { codeToHtml } from "shiki";
import reactToText from "react-to-text";
import { CodeIcon, TerminalIcon } from "lucide-react";
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";
const CodeBlock = async ({
showLineNumbers = false,
showCopyButton = true,
className,
children,
...rest
}: React.ComponentProps<"pre"> & {
interface CodeBlockProps extends React.ComponentProps<"pre"> {
showLineNumbers?: boolean;
showCopyButton?: boolean;
}) => {
}
const renderHighlightedCode = async (codeString: string, lang: string) => {
"use cache";
cacheLife("max");
const html = await codeToHtml(codeString, {
lang,
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
if (!children || typeof children !== "object" || !("props" in children)) {
return (
<pre className={className} {...rest}>
{children}
</pre>
);
return <pre {...props}>{children}</pre>;
}
const codeProps = children.props as React.ComponentProps<"code">;
@@ -29,13 +39,7 @@ const CodeBlock = async ({
// the language set in the markdown is passed as a className
const lang = codeProps.className?.split("language-")[1] ?? "";
const codeHighlighted = await codeToHtml(codeString, {
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
});
const html = await renderHighlightedCode(codeString, lang);
return (
<div className={cn("bg-muted/35 relative isolate rounded-lg border-2 font-mono shadow", className)}>
@@ -47,11 +51,11 @@ const CodeBlock = async ({
)}
data-language={lang || undefined}
data-line-numbers={showLineNumbers || undefined}
dangerouslySetInnerHTML={{ __html: codeHighlighted }}
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"].includes(lang) ? (
{["sh", "bash", "zsh", "shell"].includes(lang) ? (
<>
<TerminalIcon />
<span>Shell</span>
+1 -1
View File
@@ -20,7 +20,7 @@ const Footer = ({ className, ...rest }: React.ComponentProps<"footer">) => {
<Link href="/previously" title="Previously on..." className="text-foreground/85 hover:no-underline">
{siteConfig.copyrightYearStart}
</Link>{" "}
{new Date().getUTCFullYear()}.
2025.
</div>
<div>
-1
View File
@@ -10,7 +10,6 @@ const Header = ({ className, ...rest }: React.ComponentProps<"header">) => {
return (
<header className={cn("flex items-center justify-between", className)} {...rest}>
<Link
dynamicOnHover
href="/"
rel="author"
aria-label={siteConfig.name}
-1
View File
@@ -31,7 +31,6 @@ const MenuItem = ({
if (href) {
return (
<Link
dynamicOnHover
href={href}
aria-label={text}
data-current={current || undefined}
+4 -22
View File
@@ -1,18 +1,7 @@
import NextLink from "next/link";
import { cn } from "@/lib/utils";
const Link = ({
href,
rel,
target,
prefetch = false,
dynamicOnHover,
className,
...rest
}: React.ComponentProps<typeof NextLink> & {
// https://github.com/vercel/next.js/pull/77866/files#diff-040f76a8f302dd3a8ec7de0867048475271f052b094cd73d2d0751b495c02f7dR30
dynamicOnHover?: boolean;
}) => {
const Link = ({ href, rel, target, className, ...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]);
@@ -26,21 +15,14 @@ const Link = ({
className
),
...rest,
} as React.ComponentProps<"a">;
};
// don't waste time with next's component if it's just an external link
if (isExternal) {
return <a {...linkProps} />;
return <a {...(linkProps as unknown as React.ComponentProps<"a">)} />;
}
return (
<NextLink
{...linkProps}
prefetch={dynamicOnHover ? null : prefetch}
// @ts-expect-error
unstable_dynamicOnHover={dynamicOnHover}
/>
);
return <NextLink {...linkProps} />;
};
export default Link;
+4 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { createContext, useEffect, useState } from "react";
import { createContext, useEffect } from "react";
import { useLocalStorage, useMedia } from "react-use";
export const ThemeContext = createContext<{
@@ -21,16 +21,11 @@ export const ThemeContext = createContext<{
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 });
// keep track of changes to the user's OS/browser dark mode setting
const [systemTheme, setSystemTheme] = useState("");
// hook into system `prefers-dark-mode` setting
// 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);
// listen for changes in OS preference, but don't save it as a website preference to local storage
useEffect(() => {
setSystemTheme(isSystemDark ? "dark" : "light");
}, [isSystemDark]);
// 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(() => {
+41 -32
View File
@@ -1,51 +1,60 @@
import { unstable_cache as cache } from "next/cache";
import { cacheLife, cacheTag } from "next/cache";
import Image from "next/image";
import type { Tweet as TweetType } from "react-tweet/api";
import { EmbeddedTweet, TweetNotFound } from "react-tweet";
import { fetchTweet as _fetchTweet } from "react-tweet/api";
import { cn } from "@/lib/utils";
const fetchTweet = cache(_fetchTweet, undefined, {
revalidate: false, // cache indefinitely
tags: ["tweet"],
});
const fetchTweet = async (id: string) => {
"use cache";
cacheLife("max"); // cache indefinitely
cacheTag("tweet", `tweet-${id}`);
return _fetchTweet(id);
};
const TweetContent = ({ data, className }: { data: TweetType; className?: string }) => {
return (
<div
className={cn(
"my-6 min-h-30",
"*:[--tweet-body-font-size:var(--text-base)]! *:[--tweet-body-line-height:var(--leading-normal)]! *:[--tweet-container-margin:0_auto]! *:[--tweet-font-family:var(--font-sans)]! *:[--tweet-info-font-size:var(--text-sm)]! *:[--tweet-info-line-height:var(--leading-normal)]!",
className
)}
>
<EmbeddedTweet
tweet={data}
components={{
// https://react-tweet.vercel.app/twitter-theme/api-reference#custom-tweet-components
// eslint-disable-next-line jsx-a11y/alt-text
AvatarImg: (props) => <Image {...props} unoptimized />,
// eslint-disable-next-line jsx-a11y/alt-text
MediaImg: (props) => <Image {...props} fill unoptimized />,
}}
/>
</div>
);
};
const Tweet = async ({ id, className }: { id: string; className?: string }) => {
try {
const { data } = await fetchTweet(id);
let data: TweetType | undefined;
return (
<div
className={cn(
"my-6 min-h-30",
"*:[--tweet-body-font-size:var(--text-base)]! *:[--tweet-body-line-height:var(--leading-normal)]! *:[--tweet-container-margin:0_auto]! *:[--tweet-font-family:var(--font-sans)]! *:[--tweet-info-font-size:var(--text-sm)]! *:[--tweet-info-line-height:var(--leading-normal)]!",
className
)}
>
{data ? (
<EmbeddedTweet
tweet={data}
components={{
// https://react-tweet.vercel.app/twitter-theme/api-reference#custom-tweet-components
// eslint-disable-next-line jsx-a11y/alt-text
AvatarImg: (props) => <Image {...props} unoptimized />,
// eslint-disable-next-line jsx-a11y/alt-text
MediaImg: (props) => <Image {...props} fill unoptimized />,
}}
/>
) : (
<TweetNotFound />
)}
</div>
);
try {
const result = await fetchTweet(id);
data = result?.data;
} catch (error) {
console.error(error);
}
if (!data) {
return (
<div className={cn("min-h-30 *:mx-auto! *:font-sans!", className)}>
<div className={cn("my-6 min-h-30 *:mx-auto! *:font-sans!", className)}>
<TweetNotFound />
</div>
);
}
return <TweetContent data={data} className={className} />;
};
export default Tweet;
+29 -16
View File
@@ -1,26 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import { env } from "@/lib/env";
import { connection } from "next/server";
import CountUp from "@/components/count-up";
import { incrementViews } from "@/lib/views";
import { incrementViews } from "@/lib/server/views";
const ViewCounter = async ({ slug }: { slug: string }) => {
// ensure this component isn't triggered by prerenders and/or preloads
await connection();
const ViewCounter = ({ slug }: { slug: string }) => {
const [views, setViews] = useState<number | null>(null);
const [error, setError] = useState(false);
try {
const hits = await incrementViews(slug);
// we have data!
return (
<span title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(hits)} ${hits === 1 ? "view" : "views"}`}>
<CountUp start={0} end={hits} delay={0} duration={1.5} />
</span>
);
} catch (error) {
console.error("[view-counter] fatal error:", error);
useEffect(() => {
// Increment views on client mount (outside of render phase)
incrementViews(slug)
.then((hits) => {
setViews(hits);
})
.catch((error) => {
console.error("[view-counter] error:", error);
setError(true);
});
}, [slug]);
if (error) {
return <span title="Error getting views! :(">?</span>;
}
if (views === null) {
return <span className="motion-safe:animate-pulse">0</span>;
}
return (
<span title={`${Intl.NumberFormat(env.NEXT_PUBLIC_SITE_LOCALE).format(views)} ${views === 1 ? "view" : "views"}`}>
<CountUp start={0} end={views} delay={0} duration={1.5} />
</span>
);
};
export default ViewCounter;
+28 -16
View File
@@ -1,7 +1,8 @@
/* eslint-disable import/no-anonymous-default-export */
import { defineConfig, globalIgnores } from "eslint/config";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import * as eslintPluginMdx from "eslint-plugin-mdx";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import eslintCustomConfig from "@jakejarvis/eslint-config";
@@ -12,23 +13,19 @@ const compat = new FlatCompat({
recommendedConfig: js.configs.recommended,
});
/** @type {import("eslint").Linter.Config[]} */
export default [
{
ignores: ["README.md", "next-env.d.ts", ".next", ".vercel", "node_modules", "lib/db/migrations"],
},
const eslintConfig = defineConfig([
// Next.js core-web-vitals and TypeScript configs
...nextVitals,
...nextTs,
// Other plugins via compat
...compat.config({
plugins: ["react-compiler", "css-modules"],
extends: [
"eslint:recommended",
"next/core-web-vitals",
"next/typescript",
"plugin:css-modules/recommended",
"plugin:drizzle/recommended",
],
extends: ["plugin:css-modules/recommended", "plugin:drizzle/recommended"],
}),
// Custom configs
...eslintCustomConfig,
eslintPluginPrettierRecommended,
// Custom rules
{
rules: {
camelcase: [
@@ -54,16 +51,31 @@ export default [
"react-compiler/react-compiler": "error",
},
},
// MDX support
{
...eslintPluginMdx.flat,
processor: eslintPluginMdx.createRemarkProcessor({
lintCodeBlocks: false,
}),
rules: {
"mdx/remark": "warn",
...eslintPluginMdx.flat.rules,
"mdx/remark": "warn", // keep as warn (matches default)
"mdx/code-blocks": "off",
"react/jsx-no-undef": "off", // components are injected automatically from mdx-components.ts
"react/no-unescaped-entities": "off",
"@typescript-eslint/no-unused-vars": "off", // MDX files often import components that are used implicitly
},
},
];
// Ignores (override Next.js defaults)
globalIgnores([
".next/**",
"out/**",
"build/**",
".vercel/**",
"next-env.d.ts",
"node_modules/**",
"lib/db/migrations/**",
]),
]);
export default eslintConfig;
+58 -58
View File
@@ -1,5 +1,4 @@
import { env } from "@/lib/env";
import { cache } from "react";
import path from "path";
import fs from "fs/promises";
import glob from "fast-glob";
@@ -31,7 +30,9 @@ export type FrontMatter = {
export const POSTS_DIR = "notes" as const;
/** Use filesystem to get a simple list of all post slugs */
export const getSlugs = cache(async (): Promise<string[]> => {
export const getSlugs = async (): Promise<string[]> => {
"use cache";
// list all .mdx files in POSTS_DIR
const mdxFiles = await glob("*/index.mdx", {
cwd: path.join(process.cwd(), POSTS_DIR),
@@ -42,7 +43,7 @@ export const getSlugs = cache(async (): Promise<string[]> => {
const slugs = mdxFiles.map((fileName) => fileName.replace(/\/index\.mdx$/, ""));
return slugs;
});
};
export const getFrontMatter: {
/**
@@ -53,67 +54,66 @@ export const getFrontMatter: {
* Parses and returns the front matter of a given slug, or undefined if the slug does not exist
*/
(slug: string): Promise<FrontMatter | undefined>;
} = cache(
async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
if (typeof slug === "string") {
try {
const { frontmatter } = await import(`../${POSTS_DIR}/${slug}/index.mdx`);
} = (async (slug?: string) => {
"use cache";
// process markdown title to html...
const htmlTitle = await unified()
.use(remarkParse)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
// allow *very* limited markdown to be used in post titles
tagNames: ["code", "em", "strong"],
})
.use(rehypeStringify)
.process(frontmatter.title)
.then((result) => result.toString().trim());
if (typeof slug === "string") {
try {
const { frontmatter } = await import(`../${POSTS_DIR}/${slug}/index.mdx`);
// ...and then (sketchily) remove said html for a plaintext version:
// https://css-tricks.com/snippets/javascript/strip-html-tags-in-javascript/
const title = decode(htmlTitle.replace(/<[^>]*>/g, ""));
// process markdown title to html...
const htmlTitle = await unified()
.use(remarkParse)
.use(remarkSmartypants)
.use(remarkRehype)
.use(rehypeSanitize, {
// allow *very* limited markdown to be used in post titles
tagNames: ["code", "em", "strong"],
})
.use(rehypeStringify)
.process(frontmatter.title)
.then((result) => result.toString().trim());
return {
...(frontmatter as Partial<FrontMatter>),
// plain title without html or markdown syntax:
title,
// stylized title with limited html tags:
htmlTitle,
slug,
// validate/normalize the date string provided from front matter
date: new Date(frontmatter.date).toISOString(),
permalink: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${slug}`,
} as FrontMatter;
} catch (error) {
console.error(`Failed to load front matter for post with slug "${slug}":`, error);
return undefined;
}
// ...and then (sketchily) remove said html for a plaintext version:
// https://css-tricks.com/snippets/javascript/strip-html-tags-in-javascript/
const title = decode(htmlTitle.replace(/<[^>]*>/g, ""));
return {
...(frontmatter as Partial<FrontMatter>),
// plain title without html or markdown syntax:
title,
// stylized title with limited html tags:
htmlTitle,
slug,
// validate/normalize the date string provided from front matter
date: new Date(frontmatter.date).toISOString(),
permalink: `${env.NEXT_PUBLIC_BASE_URL}/${POSTS_DIR}/${slug}`,
} as FrontMatter;
} catch (error) {
console.error(`Failed to load front matter for post with slug "${slug}":`, error);
return undefined;
}
if (!slug) {
// concurrently fetch the front matter of each post
const slugs = await getSlugs();
const posts = await Promise.all(slugs.map(getFrontMatter));
// sort the results reverse chronologically and return
return posts.sort(
(post1, post2) => new Date(post2!.date).getTime() - new Date(post1!.date).getTime()
) as FrontMatter[];
}
throw new Error("getFrontMatter() called with invalid argument.");
}
);
if (!slug) {
// concurrently fetch the front matter of each post
const slugs = await getSlugs();
const allPosts = await Promise.all(slugs.map(getFrontMatter));
// filter out any undefined entries from failed imports
const posts = allPosts.filter((p): p is FrontMatter => !!p);
// sort the results reverse chronologically and return
return posts.sort((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime());
}
throw new Error("getFrontMatter() called with invalid argument.");
}) as typeof getFrontMatter;
/** Returns the content of a post with very limited processing to include in RSS feeds */
export const getContent = cache(async (slug: string): Promise<string | undefined> => {
export const getContent = async (slug: string): Promise<string | undefined> => {
"use cache";
try {
const content = await unified()
.use(remarkParse)
@@ -153,4 +153,4 @@ export const getContent = cache(async (slug: string): Promise<string | undefined
console.error(`Failed to load/parse content for post with slug "${slug}":`, error);
return undefined;
}
});
};
+22 -6
View File
@@ -1,7 +1,7 @@
"use server";
import { headers } from "next/headers";
import { revalidatePath } from "next/cache";
import { revalidatePath, revalidateTag, cacheTag } from "next/cache";
import { eq, desc, inArray, sql } from "drizzle-orm";
import { checkBotId } from "botid/server";
import { db } from "@/lib/db";
@@ -13,6 +13,9 @@ export type CommentWithUser = typeof schema.comment.$inferSelect & {
};
export const getComments = async (pageSlug: string): Promise<CommentWithUser[]> => {
"use cache";
cacheTag("comments", `comments-${pageSlug}`);
try {
// Fetch all comments for the page with user details
const commentsWithUsers = await db
@@ -33,7 +36,8 @@ export const getComments = async (pageSlug: string): Promise<CommentWithUser[]>
}));
} catch (error) {
console.error("[server/comments] error fetching comments:", error);
throw new Error("Failed to fetch comments");
// Return empty array instead of throwing during prerendering
return [];
}
};
@@ -55,6 +59,9 @@ export const getCommentCounts: {
slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
"use cache";
cacheTag("comments");
try {
// return one page
if (typeof slug === "string") {
@@ -102,7 +109,10 @@ Promise<any> => {
return map;
} catch (error) {
console.error("[server/comments] error fetching comment counts:", error);
throw new Error("Failed to fetch comment counts");
// Return sensible defaults instead of throwing during prerendering
if (typeof slug === "string") return 0;
if (Array.isArray(slug)) return Object.fromEntries(slug.map((s: string) => [s, 0]));
return {};
}
};
@@ -131,7 +141,9 @@ export const createComment = async (data: { content: string; pageSlug: string; p
userId: session.user.id,
});
// Revalidate the page to show the new comment
// Revalidate caches and paths
revalidateTag("comments", "max");
revalidateTag(`comments-${data.pageSlug}`, "max");
revalidatePath(`/${data.pageSlug}`);
// Also revalidate the notes listing to update comment count badges
revalidatePath("/notes");
@@ -183,7 +195,9 @@ export const updateComment = async (commentId: string, content: string) => {
})
.where(eq(schema.comment.id, commentId));
// Revalidate the page to show the updated comment
// Revalidate caches and paths
revalidateTag("comments", "max");
revalidateTag(`comments-${comment.pageSlug}`, "max");
revalidatePath(`/${comment.pageSlug}`);
// Also revalidate the notes listing to update comment count badges
// TODO: make this more generic in case we want to add comments to non-note pages
@@ -230,7 +244,9 @@ export const deleteComment = async (commentId: string) => {
// Delete the comment
await db.delete(schema.comment).where(eq(schema.comment.id, commentId));
// Revalidate the page to update the comments list
// Revalidate caches and paths
revalidateTag("comments", "max");
revalidateTag(`comments-${comment.pageSlug}`, "max");
revalidatePath(`/${comment.pageSlug}`);
// Also revalidate the notes listing to update comment count badges
// TODO: make this more generic in case we want to add comments to non-note pages
+29
View File
@@ -0,0 +1,29 @@
"use server";
import { sql } from "drizzle-orm";
import { revalidateTag } from "next/cache";
import { db } from "@/lib/db";
import { page } from "@/lib/db/schema";
export const incrementViews = async (slug: string): Promise<number> => {
try {
// Atomic upsert: insert new row with views=1, or increment existing row
const [result] = await db
.insert(page)
.values({ slug, views: 1 })
.onConflictDoUpdate({
target: page.slug,
set: { views: sql`${page.views} + 1` },
})
.returning({ views: page.views });
// Invalidate the views cache so getViewCounts returns fresh data
// This is safe here because this entire file is marked "use server"
revalidateTag("views", "max");
return result.views;
} catch (error) {
console.error("[actions/views] error incrementing views:", error);
throw new Error("Failed to increment views");
}
};
+20 -44
View File
@@ -1,69 +1,42 @@
import "server-only";
import { eq, inArray } from "drizzle-orm";
import { cacheTag } from "next/cache";
import { db } from "@/lib/db";
import { page } from "@/lib/db/schema";
export const incrementViews = async (slug: string): Promise<number> => {
try {
// First, try to find the existing record
const existingPage = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
if (existingPage.length === 0) {
// Create new record if it doesn't exist
await db.insert(page).values({ slug, views: 1 }).execute();
return 1; // New record starts with 1 hit
} else {
// Calculate new hit count
const newViewCount = existingPage[0].views + 1;
// Update existing record by incrementing hits
await db.update(page).set({ views: newViewCount }).where(eq(page.slug, slug)).execute();
return newViewCount;
}
} catch (error) {
console.error("[view-counter] fatal error:", error);
throw new Error("Failed to increment views");
}
};
export const getViewCounts: {
/**
* Retrieves the number of views for a given slug, or null if the slug does not exist
* Retrieves the number of views for a given slug, or 0 if the slug does not exist or on error
*/
(slug: string): Promise<number | null>;
(slug: string): Promise<number>;
/**
* Retrieves the numbers of views for an array of slugs
* Retrieves the numbers of views for an array of slugs, returning 0 for any that don't exist
*/
(slug: string[]): Promise<Record<string, number | null>>;
(slug: string[]): Promise<Record<string, number>>;
/**
* Retrieves the numbers of views for ALL slugs
*/
(): Promise<Record<string, number>>;
} = async (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
} = (async (slug?: string | string[]) => {
"use cache";
cacheTag("views");
try {
// return one page
if (typeof slug === "string") {
const pages = await db.select().from(page).where(eq(page.slug, slug)).limit(1);
return pages[0].views;
return pages[0]?.views ?? 0; // Return 0 if no row found
}
// return multiple pages
if (Array.isArray(slug)) {
const pages = await db.select().from(page).where(inArray(page.slug, slug));
return pages.reduce(
(acc, page, index) => {
acc[slug[index]] = page.views;
return acc;
},
{} as Record<string, number | null>
);
const viewMap: Record<string, number> = Object.fromEntries(slug.map((s) => [s, 0]));
for (const p of pages) {
viewMap[p.slug] = p.views;
}
return viewMap;
}
// return ALL pages
@@ -77,6 +50,9 @@ Promise<any> => {
);
} catch (error) {
console.error("[server/views] fatal error:", error);
throw new Error("Failed to get views");
// Return sensible defaults instead of throwing during prerendering
if (typeof slug === "string") return 0;
if (Array.isArray(slug)) return Object.fromEntries(slug.map((s) => [s, 0]));
return {};
}
};
}) as typeof getViewCounts;
+8 -26
View File
@@ -1,5 +1,3 @@
import * as remarkPlugins from "./lib/remark";
import * as rehypePlugins from "./lib/rehype";
import type { NextConfig } from "next";
// check environment variables at build time
@@ -7,14 +5,10 @@ import type { NextConfig } from "next";
import "./lib/env";
const nextConfig = {
cacheComponents: true,
reactStrictMode: true,
reactCompiler: true,
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: {
qualities: [50, 75, 100],
remotePatterns: [
@@ -38,9 +32,6 @@ const nextConfig = {
},
productionBrowserSourceMaps: true,
experimental: {
reactCompiler: true,
ppr: "incremental",
dynamicOnHover: true,
inlineCss: true,
serverActions: {
// fix CSRF errors from tor reverse proxy
@@ -159,30 +150,21 @@ const nextPlugins: Array<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config: NextConfig) => NextConfig | [(config: NextConfig) => NextConfig, any]
> = [
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("@next/bundle-analyzer")({
enabled: !!process.env.ANALYZE,
}),
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("@next/mdx")({
options: {
remarkPlugins: [
remarkPlugins.remarkFrontmatter,
remarkPlugins.remarkMdxFrontmatter,
remarkPlugins.remarkGfm,
remarkPlugins.remarkSmartypants,
],
remarkPlugins: ["remark-frontmatter", "remark-mdx-frontmatter", "remark-gfm", "remark-smartypants"],
rehypePlugins: [
rehypePlugins.rehypeUnwrapImages,
rehypePlugins.rehypeSlug,
"rehype-unwrap-images",
"rehype-slug",
[
rehypePlugins.rehypeWrapper,
"rehype-wrapper",
{
className: "text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5",
},
],
rehypePlugins.rehypeMdxCodeProps,
rehypePlugins.rehypeMdxImportMedia,
"rehype-mdx-code-props",
"rehype-mdx-import-media",
],
},
}),
+39 -40
View File
@@ -25,45 +25,44 @@
"@date-fns/utc": "^2.1.1",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.0.1",
"@next/bundle-analyzer": "15.5.1-canary.31",
"@next/mdx": "15.5.1-canary.31",
"@octokit/graphql": "^9.0.1",
"@neondatabase/serverless": "^1.0.2",
"@next/mdx": "16.0.3",
"@octokit/graphql": "^9.0.3",
"@octokit/graphql-schema": "^15.26.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.8",
"@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0",
"better-auth": "1.3.8",
"botid": "^1.5.4",
"better-auth": "1.3.34",
"botid": "^1.5.10",
"cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0",
"drizzle-orm": "^0.44.5",
"drizzle-orm": "^0.44.7",
"fast-glob": "^3.3.3",
"feed": "^5.1.0",
"geist": "^1.5.1",
"html-entities": "^2.6.0",
"lucide-react": "0.542.0",
"next": "15.5.1-canary.31",
"react": "19.1.1",
"react-activity-calendar": "^2.7.13",
"lucide-react": "0.554.0",
"next": "16.0.3",
"react": "19.2.0",
"react-activity-calendar": "^3.0.1",
"react-countup": "^6.5.3",
"react-dom": "19.1.1",
"react-lite-youtube-embed": "^2.5.6",
"react-dom": "19.2.0",
"react-lite-youtube-embed": "^3.3.2",
"react-markdown": "^10.1.0",
"react-schemaorg": "^2.0.0",
"react-timeago": "^8.3.0",
@@ -86,31 +85,31 @@
"remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.0.2",
"resend": "^6.5.2",
"server-only": "0.0.1",
"shiki": "^3.12.2",
"shiki": "^3.15.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.3.1",
"tailwindcss": "^4.1.13",
"tw-animate-css": "^1.3.8",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17",
"tw-animate-css": "^1.4.0",
"unified": "^11.0.5",
"zod": "4.1.5"
"zod": "4.1.12"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.35.0",
"@eslint/js": "^9.39.1",
"@jakejarvis/eslint-config": "^4.0.7",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/postcss": "^4.1.17",
"@types/mdx": "^2.0.13",
"@types/node": "^24.3.1",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@types/node": "^24.10.1",
"@types/react": "19.2.6",
"@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.0.0",
"dotenv": "^17.2.2",
"drizzle-kit": "^0.31.4",
"eslint": "^9.35.0",
"eslint-config-next": "15.5.1-canary.31",
"cross-env": "^10.1.0",
"dotenv": "^17.2.3",
"drizzle-kit": "^0.31.7",
"eslint": "^9.39.1",
"eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3",
@@ -120,22 +119,22 @@
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-hooks": "^7.0.1",
"husky": "^9.1.7",
"lint-staged": "^16.1.6",
"lint-staged": "^16.2.7",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"prettier-plugin-tailwindcss": "^0.7.1",
"schema-dts": "^1.1.5",
"typescript": "5.9.2"
"typescript": "5.9.3"
},
"optionalDependencies": {
"sharp": "^0.34.3"
"sharp": "^0.34.5"
},
"engines": {
"node": ">=22.x"
},
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67",
"packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
"cacheDirectories": [
"node_modules",
".next/cache"
+1981 -1851
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -33,7 +33,8 @@
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"