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:
-15
@@ -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,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<{
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -31,7 +31,6 @@ const MenuItem = ({
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
dynamicOnHover
|
||||
href={href}
|
||||
aria-label={text}
|
||||
data-current={current || undefined}
|
||||
|
||||
+4
-22
@@ -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;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Vendored
+41
-32
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Generated
+1981
-1851
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -33,7 +33,8 @@
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user