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

This commit is contained in:
jake authored and GitHub committed 2025-11-22 17:11:42 -05:00
1 parent 19960ca2b0
commit 8a09aa918c
27 files changed
+2346 -2193

No files matched your search

-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 { NextResponse } from "next/server";
import { unstable_cache as cache } from "next/cache"; import { getViewCounts } from "@/lib/views";
import { getViewCounts as _getViewCounts } from "@/lib/views";
const getViewCounts = cache(_getViewCounts, undefined, {
revalidate: 300, // 5 minutes
tags: ["hits"],
});
export const GET = async (): Promise< export const GET = async (): Promise<
NextResponse<{ NextResponse<{
-2
View File
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed"; import { buildFeed } from "@/lib/build-feed";
export const dynamic = "force-static";
export const GET = async () => { export const GET = async () => {
const feed = await buildFeed(); const feed = await buildFeed();
-2
View File
@@ -1,8 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed"; import { buildFeed } from "@/lib/build-feed";
export const dynamic = "force-static";
export const GET = async () => { export const GET = async () => {
const feed = await buildFeed(); const feed = await buildFeed();
-3
View File
@@ -13,9 +13,6 @@ export const size = {
height: 630, 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 () => { export const generateStaticParams = async () => {
const slugs = await getSlugs(); const slugs = await getSlugs();
+16 -14
View File
@@ -1,5 +1,6 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { Suspense } from "react"; import { Suspense } from "react";
import { cacheLife } from "next/cache";
import { JsonLd } from "react-schemaorg"; import { JsonLd } from "react-schemaorg";
import { formatDate, formatDateISO } from "@/lib/date"; import { formatDate, formatDateISO } from "@/lib/date";
import { CalendarDaysIcon, TagIcon, SquarePenIcon, EyeIcon, MessagesSquareIcon } from "lucide-react"; 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 { Metadata } from "next";
import type { BlogPosting } from "schema-dts"; 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 () => { export const generateStaticParams = async () => {
const slugs = await getSlugs(); 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 Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params; const { slug } = await params;
const frontmatter = await getFrontMatter(slug); const frontmatter = await getFrontMatter(slug);
const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`); const commentCount = await getCommentCounts(`${POSTS_DIR}/${slug}`);
const formattedDates = await getFormattedDates(frontmatter!.date);
const { default: MDXContent } = await import(`../../../${POSTS_DIR}/${slug}/index.mdx`); 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"} 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" />
<time dateTime={formatDateISO(frontmatter!.date)} title={formatDate(frontmatter!.date, "MMM d, y, h:mm a O")}> <time dateTime={formattedDates.dateISO} title={formattedDates.dateTitle}>
{formatDate(frontmatter!.date, "MMMM d, y")} {formattedDates.dateDisplay}
</time> </time>
</Link> </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"> <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" />
<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}`} /> <ViewCounter slug={`${POSTS_DIR}/${frontmatter!.slug}`} />
</Suspense>
</div> </div>
</div> </div>
+76 -41
View File
@@ -1,4 +1,5 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { cacheLife } from "next/cache";
import { EyeIcon, MessagesSquareIcon } from "lucide-react"; import { EyeIcon, MessagesSquareIcon } from "lucide-react";
import Link from "@/components/link"; import Link from "@/components/link";
import { getFrontMatter, POSTS_DIR, type FrontMatter } from "@/lib/posts"; 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 { getViewCounts } from "@/lib/views";
import { getCommentCounts } from "@/lib/server/comments"; import { getCommentCounts } from "@/lib/server/comments";
export const revalidate = 300; // 5 minutes
export const metadata = createMetadata({ export const metadata = createMetadata({
title: "Notes", title: "Notes",
description: `Recent posts by ${authorConfig.name}.`, description: `Recent posts by ${authorConfig.name}.`,
canonical: `/${POSTS_DIR}`, canonical: `/${POSTS_DIR}`,
}); });
const Page = async () => { // Hoist number formatter to avoid re-creating on every render
// parse the year of each post and group them together 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()]); 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: { 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) => { formattedPosts.forEach((post) => {
const year = new Date(post.date).getUTCFullYear(); (postsByYear[post.year] || (postsByYear[post.year] = [])).push({
(postsByYear[year] || (postsByYear[year] = [])).push({
...post, ...post,
// Include pre-fetched stats
views: views[`${POSTS_DIR}/${post.slug}`] || 0, views: views[`${POSTS_DIR}/${post.slug}`] || 0,
comments: comments[`${POSTS_DIR}/${post.slug}`] || 0, comments: comments[`${POSTS_DIR}/${post.slug}`] || 0,
}); });
@@ -42,43 +100,18 @@ const Page = async () => {
{year} {year}
</h2> </h2>
<ul className="space-y-4"> <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}> <li className="flex text-base leading-relaxed" key={slug}>
<span className="text-muted-foreground w-18 shrink-0 md:w-22"> <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")}> <time dateTime={dateISO} title={dateTitle}>
{formatDate(date, "MMM d")} {dateDisplay}
</time> </time>
</span> </span>
<div className="space-x-2.5"> <div className="space-x-2.5">
<Link {/* htmlTitle is sanitized by rehypeSanitize in lib/posts.ts with strict allowlist: only code, em, strong tags */}
dynamicOnHover <Link href={`/${POSTS_DIR}/${slug}`} dangerouslySetInnerHTML={{ __html: htmlTitle || title }} />
href={`/${POSTS_DIR}/${slug}`}
dangerouslySetInnerHTML={{ __html: htmlTitle || title }}
/>
{views > 0 && ( <PostStats slug={slug} views={views} comments={comments} />
<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>
)}
</div> </div>
</li> </li>
))} ))}
@@ -88,9 +121,11 @@ const Page = async () => {
}); });
// grouped posts enter this component ordered chronologically -- we want reverse chronological // 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; export default Page;
+7 -19
View File
@@ -1,6 +1,7 @@
import "server-only"; import "server-only";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { cacheLife } from "next/cache";
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import { graphql } from "@octokit/graphql"; import { graphql } from "@octokit/graphql";
import type { Repository, User } from "@octokit/graphql-schema"; import type { Repository, User } from "@octokit/graphql-schema";
@@ -12,6 +13,9 @@ export const getContributions = async (): Promise<
level: number; level: number;
}> }>
> => { > => {
"use cache";
cacheLife("minutes");
// thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts // thanks @grubersjoe! :) https://github.com/grubersjoe/github-contributions-api/blob/main/src/scrape.ts
try { try {
const response = await fetch(`https://github.com/users/${env.NEXT_PUBLIC_GITHUB_USERNAME}/contributions`, { 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}`, referer: `https://github.com/${env.NEXT_PUBLIC_GITHUB_USERNAME}`,
"x-requested-with": "XMLHttpRequest", "x-requested-with": "XMLHttpRequest",
}, },
cache: "force-cache",
next: {
revalidate: 900, // 15 minutes
tags: ["github-contributions"],
},
}); });
const $ = cheerio.load(await response.text()); const $ = cheerio.load(await response.text());
@@ -78,6 +77,9 @@ export const getContributions = async (): Promise<
}; };
export const getRepos = async (): Promise<Repository[] | undefined> => { export const getRepos = async (): Promise<Repository[] | undefined> => {
"use cache";
cacheLife("minutes");
try { try {
// https://docs.github.com/en/graphql/reference/objects#repository // https://docs.github.com/en/graphql/reference/objects#repository
const { user } = await graphql<{ user: User }>( const { user } = await graphql<{ user: User }>(
@@ -118,20 +120,6 @@ export const getRepos = async (): Promise<Repository[] | undefined> => {
accept: "application/vnd.github.v3+json", accept: "application/vnd.github.v3+json",
authorization: `token ${env.GITHUB_TOKEN}`, 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"],
},
});
},
},
} }
); );
+11 -1
View File
@@ -42,9 +42,13 @@ const Page = async () => {
</h2> </h2>
<Suspense fallback={<p>Failed to generate activity calendar.</p>}> <Suspense fallback={<p>Failed to generate activity calendar.</p>}>
{contributions.length > 0 ? (
<div className={cn("mx-auto mt-4 mb-8")}> <div className={cn("mx-auto mt-4 mb-8")}>
<ActivityCalendar data={contributions} noun="contribution" /> <ActivityCalendar data={contributions} noun="contribution" />
</div> </div>
) : (
<p className="text-muted-foreground my-4 text-center">Unable to load contribution data at this time.</p>
)}
</Suspense> </Suspense>
<h2 className="my-3.5 text-xl font-medium"> <h2 className="my-3.5 text-xl font-medium">
@@ -56,8 +60,9 @@ const Page = async () => {
</Link> </Link>
</h2> </h2>
{repos && repos.length > 0 ? (
<div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2"> <div className="row-auto grid w-full grid-cols-none gap-4 md:grid-cols-2">
{repos?.map((repo) => ( {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"> <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"> <Link href={repo!.url} className="inline-block text-base leading-relaxed font-semibold">
{repo!.name} {repo!.name}
@@ -101,14 +106,19 @@ const Page = async () => {
)} )}
<div className="text-muted-foreground whitespace-nowrap"> <div className="text-muted-foreground whitespace-nowrap">
<Suspense fallback={<span>Updated recently</span>}>
<span> <span>
Updated <RelativeTime date={repo!.pushedAt} /> Updated <RelativeTime date={repo!.pushedAt} />
</span> </span>
</Suspense>
</div> </div>
</div> </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"> <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"> <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 { CodeIcon, TerminalIcon } from "lucide-react";
import { cacheLife } from "next/cache";
import CopyButton from "@/components/copy-button"; import CopyButton from "@/components/copy-button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import reactToText from "react-to-text";
import { codeToHtml } from "shiki";
const CodeBlock = async ({ interface CodeBlockProps extends React.ComponentProps<"pre"> {
showLineNumbers = false,
showCopyButton = true,
className,
children,
...rest
}: React.ComponentProps<"pre"> & {
showLineNumbers?: boolean; showLineNumbers?: boolean;
showCopyButton?: 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 // escape hatch if this code wasn't meant to be highlighted
if (!children || typeof children !== "object" || !("props" in children)) { if (!children || typeof children !== "object" || !("props" in children)) {
return ( return <pre {...props}>{children}</pre>;
<pre className={className} {...rest}>
{children}
</pre>
);
} }
const codeProps = children.props as React.ComponentProps<"code">; 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 // the language set in the markdown is passed as a className
const lang = codeProps.className?.split("language-")[1] ?? ""; const lang = codeProps.className?.split("language-")[1] ?? "";
const codeHighlighted = await codeToHtml(codeString, { const html = await renderHighlightedCode(codeString, lang);
lang,
themes: {
light: "github-light",
dark: "github-dark",
},
});
return ( return (
<div className={cn("bg-muted/35 relative isolate rounded-lg border-2 font-mono shadow", className)}> <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-language={lang || undefined}
data-line-numbers={showLineNumbers || undefined} data-line-numbers={showLineNumbers || undefined}
dangerouslySetInnerHTML={{ __html: codeHighlighted }} dangerouslySetInnerHTML={{ __html: html }}
/> />
{lang && ( {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"> <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 /> <TerminalIcon />
<span>Shell</span> <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"> <Link href="/previously" title="Previously on..." className="text-foreground/85 hover:no-underline">
{siteConfig.copyrightYearStart} {siteConfig.copyrightYearStart}
</Link>{" "} </Link>{" "}
– {new Date().getUTCFullYear()}. – 2025.
</div> </div>
<div> <div>
-1
View File
@@ -10,7 +10,6 @@ const Header = ({ className, ...rest }: React.ComponentProps<"header">) => {
return ( return (
<header className={cn("flex items-center justify-between", className)} {...rest}> <header className={cn("flex items-center justify-between", className)} {...rest}>
<Link <Link
dynamicOnHover
href="/" href="/"
rel="author" rel="author"
aria-label={siteConfig.name} aria-label={siteConfig.name}
-1
View File
@@ -31,7 +31,6 @@ const MenuItem = ({
if (href) { if (href) {
return ( return (
<Link <Link
dynamicOnHover
href={href} href={href}
aria-label={text} aria-label={text}
data-current={current || undefined} data-current={current || undefined}
+4 -22
View File
@@ -1,18 +1,7 @@
import NextLink from "next/link"; import NextLink from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const Link = ({ const Link = ({ href, rel, target, className, ...rest }: React.ComponentProps<typeof NextLink>) => {
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;
}) => {
// This component auto-detects whether or not this link should open in the same window (the default for internal // 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"`. // 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]); const isExternal = typeof href === "string" && !["/", "#"].includes(href[0]);
@@ -26,21 +15,14 @@ const Link = ({
className className
), ),
...rest, ...rest,
} as React.ComponentProps<"a">; };
// don't waste time with next's component if it's just an external link // don't waste time with next's component if it's just an external link
if (isExternal) { if (isExternal) {
return <a {...linkProps} />; return <a {...(linkProps as unknown as React.ComponentProps<"a">)} />;
} }
return ( return <NextLink {...linkProps} />;
<NextLink
{...linkProps}
prefetch={dynamicOnHover ? null : prefetch}
// @ts-expect-error
unstable_dynamicOnHover={dynamicOnHover}
/>
);
}; };
export default Link; export default Link;
+4 -9
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import { createContext, useEffect, useState } from "react"; import { createContext, useEffect } from "react";
import { useLocalStorage, useMedia } from "react-use"; import { useLocalStorage, useMedia } from "react-use";
export const ThemeContext = createContext<{ export const ThemeContext = createContext<{
@@ -21,16 +21,11 @@ export const ThemeContext = createContext<{
export const ThemeProvider = ({ children }: React.PropsWithChildren) => { export const ThemeProvider = ({ children }: React.PropsWithChildren) => {
// keep track of if/when the user has set their theme *on this site* // keep track of if/when the user has set their theme *on this site*
const [preferredTheme, setPreferredTheme] = useLocalStorage<string>("theme", undefined, { raw: true }); const [preferredTheme, setPreferredTheme] = useLocalStorage<string>("theme", undefined, { raw: true });
// keep track of changes to the user's OS/browser dark mode setting // hook into system `prefers-color-scheme` setting
const [systemTheme, setSystemTheme] = useState("");
// hook into system `prefers-dark-mode` setting
// https://web.dev/prefers-color-scheme/#the-prefers-color-scheme-media-query // https://web.dev/prefers-color-scheme/#the-prefers-color-scheme-media-query
const isSystemDark = useMedia("(prefers-color-scheme: dark)", false); const isSystemDark = useMedia("(prefers-color-scheme: dark)", false);
// Derive system theme directly from media query to avoid setState in effect
// listen for changes in OS preference, but don't save it as a website preference to local storage const systemTheme = isSystemDark ? "dark" : "light";
useEffect(() => {
setSystemTheme(isSystemDark ? "dark" : "light");
}, [isSystemDark]);
// actual DOM updates must be done in useEffect // actual DOM updates must be done in useEffect
useEffect(() => { useEffect(() => {
+22 -13
View File
@@ -1,18 +1,19 @@
import { unstable_cache as cache } from "next/cache"; import { cacheLife, cacheTag } from "next/cache";
import Image from "next/image"; import Image from "next/image";
import type { Tweet as TweetType } from "react-tweet/api";
import { EmbeddedTweet, TweetNotFound } from "react-tweet"; import { EmbeddedTweet, TweetNotFound } from "react-tweet";
import { fetchTweet as _fetchTweet } from "react-tweet/api"; import { fetchTweet as _fetchTweet } from "react-tweet/api";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const fetchTweet = cache(_fetchTweet, undefined, { const fetchTweet = async (id: string) => {
revalidate: false, // cache indefinitely "use cache";
tags: ["tweet"], cacheLife("max"); // cache indefinitely
}); cacheTag("tweet", `tweet-${id}`);
const Tweet = async ({ id, className }: { id: string; className?: string }) => { return _fetchTweet(id);
try { };
const { data } = await fetchTweet(id);
const TweetContent = ({ data, className }: { data: TweetType; className?: string }) => {
return ( return (
<div <div
className={cn( className={cn(
@@ -21,7 +22,6 @@ const Tweet = async ({ id, className }: { id: string; className?: string }) => {
className className
)} )}
> >
{data ? (
<EmbeddedTweet <EmbeddedTweet
tweet={data} tweet={data}
components={{ components={{
@@ -32,20 +32,29 @@ const Tweet = async ({ id, className }: { id: string; className?: string }) => {
MediaImg: (props) => <Image {...props} fill unoptimized />, MediaImg: (props) => <Image {...props} fill unoptimized />,
}} }}
/> />
) : (
<TweetNotFound />
)}
</div> </div>
); );
};
const Tweet = async ({ id, className }: { id: string; className?: string }) => {
let data: TweetType | undefined;
try {
const result = await fetchTweet(id);
data = result?.data;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
}
if (!data) {
return ( 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 /> <TweetNotFound />
</div> </div>
); );
} }
return <TweetContent data={data} className={className} />;
}; };
export default Tweet; export default Tweet;
+29 -16
View File
@@ -1,26 +1,39 @@
"use client";
import { useEffect, useState } from "react";
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { connection } from "next/server";
import CountUp from "@/components/count-up"; import CountUp from "@/components/count-up";
import { incrementViews } from "@/lib/views"; import { incrementViews } from "@/lib/server/views";
const ViewCounter = async ({ slug }: { slug: string }) => { const ViewCounter = ({ slug }: { slug: string }) => {
// ensure this component isn't triggered by prerenders and/or preloads const [views, setViews] = useState<number | null>(null);
await connection(); const [error, setError] = useState(false);
try { useEffect(() => {
const hits = await incrementViews(slug); // Increment views on client mount (outside of render phase)
incrementViews(slug)
// we have data! .then((hits) => {
return ( setViews(hits);
<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} /> .catch((error) => {
</span> console.error("[view-counter] error:", error);
); setError(true);
} catch (error) { });
console.error("[view-counter] fatal error:", error); }, [slug]);
if (error) {
return <span title="Error getting views! :(">?</span>; 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; 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 { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js"; 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 * as eslintPluginMdx from "eslint-plugin-mdx";
import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended";
import eslintCustomConfig from "@jakejarvis/eslint-config"; import eslintCustomConfig from "@jakejarvis/eslint-config";
@@ -12,23 +13,19 @@ const compat = new FlatCompat({
recommendedConfig: js.configs.recommended, recommendedConfig: js.configs.recommended,
}); });
/** @type {import("eslint").Linter.Config[]} */ const eslintConfig = defineConfig([
export default [ // Next.js core-web-vitals and TypeScript configs
{ ...nextVitals,
ignores: ["README.md", "next-env.d.ts", ".next", ".vercel", "node_modules", "lib/db/migrations"], ...nextTs,
}, // Other plugins via compat
...compat.config({ ...compat.config({
plugins: ["react-compiler", "css-modules"], plugins: ["react-compiler", "css-modules"],
extends: [ extends: ["plugin:css-modules/recommended", "plugin:drizzle/recommended"],
"eslint:recommended",
"next/core-web-vitals",
"next/typescript",
"plugin:css-modules/recommended",
"plugin:drizzle/recommended",
],
}), }),
// Custom configs
...eslintCustomConfig, ...eslintCustomConfig,
eslintPluginPrettierRecommended, eslintPluginPrettierRecommended,
// Custom rules
{ {
rules: { rules: {
camelcase: [ camelcase: [
@@ -54,16 +51,31 @@ export default [
"react-compiler/react-compiler": "error", "react-compiler/react-compiler": "error",
}, },
}, },
// MDX support
{ {
...eslintPluginMdx.flat, ...eslintPluginMdx.flat,
processor: eslintPluginMdx.createRemarkProcessor({ processor: eslintPluginMdx.createRemarkProcessor({
lintCodeBlocks: false, lintCodeBlocks: false,
}), }),
rules: { rules: {
"mdx/remark": "warn", ...eslintPluginMdx.flat.rules,
"mdx/remark": "warn", // keep as warn (matches default)
"mdx/code-blocks": "off", "mdx/code-blocks": "off",
"react/jsx-no-undef": "off", // components are injected automatically from mdx-components.ts "react/jsx-no-undef": "off", // components are injected automatically from mdx-components.ts
"react/no-unescaped-entities": "off", "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;
+17 -17
View File
@@ -1,5 +1,4 @@
import { env } from "@/lib/env"; import { env } from "@/lib/env";
import { cache } from "react";
import path from "path"; import path from "path";
import fs from "fs/promises"; import fs from "fs/promises";
import glob from "fast-glob"; import glob from "fast-glob";
@@ -31,7 +30,9 @@ export type FrontMatter = {
export const POSTS_DIR = "notes" as const; export const POSTS_DIR = "notes" as const;
/** Use filesystem to get a simple list of all post slugs */ /** 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 // list all .mdx files in POSTS_DIR
const mdxFiles = await glob("*/index.mdx", { const mdxFiles = await glob("*/index.mdx", {
cwd: path.join(process.cwd(), POSTS_DIR), 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$/, "")); const slugs = mdxFiles.map((fileName) => fileName.replace(/\/index\.mdx$/, ""));
return slugs; return slugs;
}); };
export const getFrontMatter: { export const getFrontMatter: {
/** /**
@@ -53,12 +54,9 @@ export const getFrontMatter: {
* Parses and returns the front matter of a given slug, or undefined if the slug does not exist * Parses and returns the front matter of a given slug, or undefined if the slug does not exist
*/ */
(slug: string): Promise<FrontMatter | undefined>; (slug: string): Promise<FrontMatter | undefined>;
} = cache( } = (async (slug?: string) => {
async ( "use cache";
// 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") { if (typeof slug === "string") {
try { try {
const { frontmatter } = await import(`../${POSTS_DIR}/${slug}/index.mdx`); const { frontmatter } = await import(`../${POSTS_DIR}/${slug}/index.mdx`);
@@ -100,20 +98,22 @@ export const getFrontMatter: {
if (!slug) { if (!slug) {
// concurrently fetch the front matter of each post // concurrently fetch the front matter of each post
const slugs = await getSlugs(); const slugs = await getSlugs();
const posts = await Promise.all(slugs.map(getFrontMatter)); 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 // sort the results reverse chronologically and return
return posts.sort( return posts.sort((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime());
(post1, post2) => new Date(post2!.date).getTime() - new Date(post1!.date).getTime()
) as FrontMatter[];
} }
throw new Error("getFrontMatter() called with invalid argument."); 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 */ /** 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 { try {
const content = await unified() const content = await unified()
.use(remarkParse) .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); console.error(`Failed to load/parse content for post with slug "${slug}":`, error);
return undefined; return undefined;
} }
}); };
+22 -6
View File
@@ -1,7 +1,7 @@
"use server"; "use server";
import { headers } from "next/headers"; 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 { eq, desc, inArray, sql } from "drizzle-orm";
import { checkBotId } from "botid/server"; import { checkBotId } from "botid/server";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
@@ -13,6 +13,9 @@ export type CommentWithUser = typeof schema.comment.$inferSelect & {
}; };
export const getComments = async (pageSlug: string): Promise<CommentWithUser[]> => { export const getComments = async (pageSlug: string): Promise<CommentWithUser[]> => {
"use cache";
cacheTag("comments", `comments-${pageSlug}`);
try { try {
// Fetch all comments for the page with user details // Fetch all comments for the page with user details
const commentsWithUsers = await db const commentsWithUsers = await db
@@ -33,7 +36,8 @@ export const getComments = async (pageSlug: string): Promise<CommentWithUser[]>
})); }));
} catch (error) { } catch (error) {
console.error("[server/comments] error fetching comments:", 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 slug?: any
): // eslint-disable-next-line @typescript-eslint/no-explicit-any ): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => { Promise<any> => {
"use cache";
cacheTag("comments");
try { try {
// return one page // return one page
if (typeof slug === "string") { if (typeof slug === "string") {
@@ -102,7 +109,10 @@ Promise<any> => {
return map; return map;
} catch (error) { } catch (error) {
console.error("[server/comments] error fetching comment counts:", 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, 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}`); revalidatePath(`/${data.pageSlug}`);
// Also revalidate the notes listing to update comment count badges // Also revalidate the notes listing to update comment count badges
revalidatePath("/notes"); revalidatePath("/notes");
@@ -183,7 +195,9 @@ export const updateComment = async (commentId: string, content: string) => {
}) })
.where(eq(schema.comment.id, commentId)); .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}`); revalidatePath(`/${comment.pageSlug}`);
// Also revalidate the notes listing to update comment count badges // 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 // 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 // Delete the comment
await db.delete(schema.comment).where(eq(schema.comment.id, commentId)); 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}`); revalidatePath(`/${comment.pageSlug}`);
// Also revalidate the notes listing to update comment count badges // 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 // 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 "server-only";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { cacheTag } from "next/cache";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { page } from "@/lib/db/schema"; 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: { 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 * Retrieves the numbers of views for ALL slugs
*/ */
(): Promise<Record<string, number>>; (): Promise<Record<string, number>>;
} = async ( } = (async (slug?: string | string[]) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any "use cache";
slug?: any cacheTag("views");
): // eslint-disable-next-line @typescript-eslint/no-explicit-any
Promise<any> => {
try { try {
// return one page // return one page
if (typeof slug === "string") { if (typeof slug === "string") {
const pages = await db.select().from(page).where(eq(page.slug, slug)).limit(1); 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 // return multiple pages
if (Array.isArray(slug)) { if (Array.isArray(slug)) {
const pages = await db.select().from(page).where(inArray(page.slug, slug)); const pages = await db.select().from(page).where(inArray(page.slug, slug));
return pages.reduce( const viewMap: Record<string, number> = Object.fromEntries(slug.map((s) => [s, 0]));
(acc, page, index) => { for (const p of pages) {
acc[slug[index]] = page.views; viewMap[p.slug] = p.views;
return acc; }
}, return viewMap;
{} as Record<string, number | null>
);
} }
// return ALL pages // return ALL pages
@@ -77,6 +50,9 @@ Promise<any> => {
); );
} catch (error) { } catch (error) {
console.error("[server/views] fatal error:", 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"; import type { NextConfig } from "next";
// check environment variables at build time // check environment variables at build time
@@ -7,14 +5,10 @@ import type { NextConfig } from "next";
import "./lib/env"; import "./lib/env";
const nextConfig = { const nextConfig = {
cacheComponents: true,
reactStrictMode: true, reactStrictMode: true,
reactCompiler: true,
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"], pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
eslint: {
ignoreDuringBuilds: true,
},
typescript: {
ignoreBuildErrors: true,
},
images: { images: {
qualities: [50, 75, 100], qualities: [50, 75, 100],
remotePatterns: [ remotePatterns: [
@@ -38,9 +32,6 @@ const nextConfig = {
}, },
productionBrowserSourceMaps: true, productionBrowserSourceMaps: true,
experimental: { experimental: {
reactCompiler: true,
ppr: "incremental",
dynamicOnHover: true,
inlineCss: true, inlineCss: true,
serverActions: { serverActions: {
// fix CSRF errors from tor reverse proxy // fix CSRF errors from tor reverse proxy
@@ -159,30 +150,21 @@ const nextPlugins: Array<
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
(config: NextConfig) => NextConfig | [(config: NextConfig) => NextConfig, 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 // eslint-disable-next-line @typescript-eslint/no-require-imports
require("@next/mdx")({ require("@next/mdx")({
options: { options: {
remarkPlugins: [ remarkPlugins: ["remark-frontmatter", "remark-mdx-frontmatter", "remark-gfm", "remark-smartypants"],
remarkPlugins.remarkFrontmatter,
remarkPlugins.remarkMdxFrontmatter,
remarkPlugins.remarkGfm,
remarkPlugins.remarkSmartypants,
],
rehypePlugins: [ rehypePlugins: [
rehypePlugins.rehypeUnwrapImages, "rehype-unwrap-images",
rehypePlugins.rehypeSlug, "rehype-slug",
[ [
rehypePlugins.rehypeWrapper, "rehype-wrapper",
{ {
className: "text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5", className: "text-[0.925rem] leading-relaxed first:mt-0 last:mb-0 md:text-base [&_p]:my-5",
}, },
], ],
rehypePlugins.rehypeMdxCodeProps, "rehype-mdx-code-props",
rehypePlugins.rehypeMdxImportMedia, "rehype-mdx-import-media",
], ],
}, },
}), }),
+39 -40
View File
@@ -25,45 +25,44 @@
"@date-fns/utc": "^2.1.1", "@date-fns/utc": "^2.1.1",
"@mdx-js/loader": "^3.1.1", "@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1", "@mdx-js/react": "^3.1.1",
"@neondatabase/serverless": "^1.0.1", "@neondatabase/serverless": "^1.0.2",
"@next/bundle-analyzer": "15.5.1-canary.31", "@next/mdx": "16.0.3",
"@next/mdx": "15.5.1-canary.31", "@octokit/graphql": "^9.0.3",
"@octokit/graphql": "^9.0.1",
"@octokit/graphql-schema": "^15.26.0", "@octokit/graphql-schema": "^15.26.0",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@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-popover": "^1.1.15",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.8", "@t3-oss/env-nextjs": "^0.13.8",
"@vercel/analytics": "^1.5.0", "@vercel/analytics": "^1.5.0",
"@vercel/speed-insights": "^1.2.0", "@vercel/speed-insights": "^1.2.0",
"better-auth": "1.3.8", "better-auth": "1.3.34",
"botid": "^1.5.4", "botid": "^1.5.10",
"cheerio": "^1.1.2", "cheerio": "^1.1.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"copy-to-clipboard": "^3.3.3", "copy-to-clipboard": "^3.3.3",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.44.5", "drizzle-orm": "^0.44.7",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"feed": "^5.1.0", "feed": "^5.1.0",
"geist": "^1.5.1", "geist": "^1.5.1",
"html-entities": "^2.6.0", "html-entities": "^2.6.0",
"lucide-react": "0.542.0", "lucide-react": "0.554.0",
"next": "15.5.1-canary.31", "next": "16.0.3",
"react": "19.1.1", "react": "19.2.0",
"react-activity-calendar": "^2.7.13", "react-activity-calendar": "^3.0.1",
"react-countup": "^6.5.3", "react-countup": "^6.5.3",
"react-dom": "19.1.1", "react-dom": "19.2.0",
"react-lite-youtube-embed": "^2.5.6", "react-lite-youtube-embed": "^3.3.2",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-schemaorg": "^2.0.0", "react-schemaorg": "^2.0.0",
"react-timeago": "^8.3.0", "react-timeago": "^8.3.0",
@@ -86,31 +85,31 @@
"remark-rehype": "^11.1.2", "remark-rehype": "^11.1.2",
"remark-smartypants": "^3.0.2", "remark-smartypants": "^3.0.2",
"remark-strip-mdx-imports-exports": "^1.0.1", "remark-strip-mdx-imports-exports": "^1.0.1",
"resend": "^6.0.2", "resend": "^6.5.2",
"server-only": "0.0.1", "server-only": "0.0.1",
"shiki": "^3.12.2", "shiki": "^3.15.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.3.1", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.17",
"tw-animate-css": "^1.3.8", "tw-animate-css": "^1.4.0",
"unified": "^11.0.5", "unified": "^11.0.5",
"zod": "4.1.5" "zod": "4.1.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3.3.1", "@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.35.0", "@eslint/js": "^9.39.1",
"@jakejarvis/eslint-config": "^4.0.7", "@jakejarvis/eslint-config": "^4.0.7",
"@tailwindcss/postcss": "^4.1.13", "@tailwindcss/postcss": "^4.1.17",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
"@types/node": "^24.3.1", "@types/node": "^24.10.1",
"@types/react": "19.1.12", "@types/react": "19.2.6",
"@types/react-dom": "19.1.9", "@types/react-dom": "19.2.3",
"babel-plugin-react-compiler": "19.1.0-rc.3", "babel-plugin-react-compiler": "19.1.0-rc.3",
"cross-env": "^10.0.0", "cross-env": "^10.1.0",
"dotenv": "^17.2.2", "dotenv": "^17.2.3",
"drizzle-kit": "^0.31.4", "drizzle-kit": "^0.31.7",
"eslint": "^9.35.0", "eslint": "^9.39.1",
"eslint-config-next": "15.5.1-canary.31", "eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-css-modules": "^2.12.0", "eslint-plugin-css-modules": "^2.12.0",
"eslint-plugin-drizzle": "^0.2.3", "eslint-plugin-drizzle": "^0.2.3",
@@ -120,22 +119,22 @@
"eslint-plugin-prettier": "^5.5.4", "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2", "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", "husky": "^9.1.7",
"lint-staged": "^16.1.6", "lint-staged": "^16.2.7",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14", "prettier-plugin-tailwindcss": "^0.7.1",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"typescript": "5.9.2" "typescript": "5.9.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"sharp": "^0.34.3" "sharp": "^0.34.5"
}, },
"engines": { "engines": {
"node": ">=22.x" "node": ">=22.x"
}, },
"packageManager": "pnpm@10.15.1+sha512.34e538c329b5553014ca8e8f4535997f96180a1d0f614339357449935350d924e22f8614682191264ec33d1462ac21561aff97f6bb18065351c162c7e8f6de67", "packageManager": "pnpm@10.23.0+sha512.21c4e5698002ade97e4efe8b8b4a89a8de3c85a37919f957e7a0f30f38fbc5bbdd05980ffe29179b2fb6e6e691242e098d945d1601772cad0fef5fb6411e2a4b",
"cacheDirectories": [ "cacheDirectories": [
"node_modules", "node_modules",
".next/cache" ".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", "next-env.d.ts",
"**/*.ts", "**/*.ts",
"**/*.tsx", "**/*.tsx",
".next/types/**/*.ts" ".next/types/**/*.ts",
".next/dev/types/**/*.ts"
], ],
"exclude": [ "exclude": [
"node_modules" "node_modules"