fix: make sitemap.xml prerenderable

This commit is contained in:
2026-08-16 18:04:40 -04:00
parent 38a00cf203
commit 9b19f9eb39
12 changed files with 457 additions and 287 deletions
+2 -2
View File
@@ -2,8 +2,8 @@ import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed";
export const GET = async () => {
const feed = await buildFeed();
export const GET = () => {
const feed = buildFeed();
return new NextResponse(feed.atom1(), {
headers: {
+2 -2
View File
@@ -2,8 +2,8 @@ import { NextResponse } from "next/server";
import { buildFeed } from "@/lib/build-feed";
export const GET = async () => {
const feed = await buildFeed();
export const GET = () => {
const feed = buildFeed();
return new NextResponse(feed.rss2(), {
headers: {
+24 -26
View File
@@ -7,13 +7,9 @@ import { ImageResponse } from "next/og";
import siteConfig from "@/lib/config/site";
import { getFrontMatter, getSlugs, POSTS_DIR } from "@/lib/posts";
// Reading Inter fonts from the local @fontsource/inter package (instead of
// fetching Google Fonts at build time) avoids flaky network timeouts on
// Vercel's build infra that caused OG image generation to fail intermittently.
// Satori supports .woff but not .woff2. The two file paths are listed explicitly
// in next.config.ts under `outputFileTracingIncludes` so NFT ships them with
// the function output.
const loadInterFont = async (weight: 400 | 600): Promise<ArrayBuffer> => {
"use cache";
const fontPath = path.join(
/* turbopackIgnore: true */ process.cwd(),
"node_modules/@fontsource/inter/files",
@@ -23,23 +19,9 @@ const loadInterFont = async (weight: 400 | 600): Promise<ArrayBuffer> => {
return Uint8Array.from(buffer).buffer;
};
export const contentType = "image/png";
export const size = {
// https://developers.facebook.com/docs/sharing/webmasters/images/
width: 1200,
height: 630,
};
export const generateStaticParams = async () => {
const slugs = await getSlugs();
// map slugs into a static paths object required by next.js
return slugs.map((slug) => ({
slug,
}));
};
const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
"use cache";
// https://stackoverflow.com/questions/5775469/whats-the-valid-way-to-include-an-image-with-no-src/14115340#14115340
const NO_IMAGE = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
@@ -68,12 +50,28 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
}
};
export const contentType = "image/png";
export const size = {
// https://developers.facebook.com/docs/sharing/webmasters/images/
width: 1200,
height: 630,
};
export const generateStaticParams = () => {
const slugs = getSlugs();
// map slugs into a static paths object required by next.js
return slugs.map((slug) => ({
slug,
}));
};
const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> }) => {
try {
const { slug } = await params;
// get the post's title and image filename from its frontmatter
const frontmatter = await getFrontMatter(slug);
const frontmatter = getFrontMatter(slug);
if (!frontmatter) notFound();
// IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes"
@@ -160,10 +158,10 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
)}
<span
style={{
fontSize: "1.825rem",
fontWeight: 600,
fontSize: "1.925rem",
fontWeight: 400,
lineHeight: "3rem",
letterSpacing: "-0.015em",
letterSpacing: "-0.025em",
marginLeft: "0.75rem",
}}
>
+4 -4
View File
@@ -18,8 +18,8 @@ import { getFrontMatter, getPost, getSlugs, POSTS_DIR } from "@/lib/posts";
import { size as ogImageSize } from "./opengraph-image";
export const generateStaticParams = async () => {
const slugs = await getSlugs();
export const generateStaticParams = () => {
const slugs = getSlugs();
// map slugs into a static paths object required by next.js
return slugs.map((slug) => ({
@@ -33,7 +33,7 @@ export const generateMetadata = async ({
params: Promise<{ slug: string }>;
}): Promise<Metadata> => {
const { slug } = await params;
const frontmatter = await getFrontMatter(slug);
const frontmatter = getFrontMatter(slug);
return createMetadata({
title: frontmatter?.title,
@@ -54,7 +54,7 @@ export const generateMetadata = async ({
const Page = async ({ params }: { params: Promise<{ slug: string }> }) => {
const { slug } = await params;
const post = await getPost(slug);
const post = getPost(slug);
if (!post) notFound();
const d = new Date(post.date);
+3 -3
View File
@@ -14,8 +14,8 @@ export const metadata = createMetadata({
canonical: `/${POSTS_DIR}`,
});
const PostsList = async () => {
const posts = await getFrontMatter();
const PostsList = () => {
const posts = getFrontMatter();
const formattedPosts = posts.map((post) => {
const d = new Date(post.date);
@@ -92,7 +92,7 @@ const PostsList = async () => {
return <>{sections.toReversed()}</>;
};
const Page = async () => (
const Page = () => (
<DirectionalTransition>
<PageTitle canonical="/notes">Notes</PageTitle>
<PostStatsProvider>
+18 -16
View File
@@ -1,10 +1,23 @@
import path from "node:path";
import glob from "fast-glob";
import type { MetadataRoute } from "next";
import { glob } from "tinyglobby";
import { getFrontMatter } from "@/lib/posts";
const getStaticRoutes = async (): Promise<string[]> => {
"use cache";
return glob("**/page.{tsx,mdx}", {
cwd: path.join(process.cwd(), "app"),
expandDirectories: false,
ignore: [
// don't include dynamic routes or route groups
"**/{\\[*\\],\\(*\\)}/page.{tsx,mdx}",
],
});
};
const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
// start with manual routes
const routes: MetadataRoute.Sitemap = [
@@ -12,25 +25,14 @@ const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
// homepage
url: process.env.NEXT_PUBLIC_BASE_URL ?? "/",
priority: 1.0,
lastModified: new Date(),
lastModified: new Date(process.env.BUILD_TIME ?? 0),
},
{ url: `${process.env.NEXT_PUBLIC_BASE_URL}/tweets` },
{ url: `${process.env.NEXT_PUBLIC_BASE_URL}/y2k` },
];
const [staticRoutes, frontmatter] = await Promise.all([
// static routes in app directory
glob("**/page.{tsx,mdx}", {
cwd: path.join(process.cwd(), "app"),
ignore: [
// don't include dynamic routes or route groups
"**/{\\[*\\],\\(*\\)}/page.{tsx,mdx}",
],
}),
// blog posts
getFrontMatter(),
]);
const staticRoutes = await getStaticRoutes();
const frontmatter = getFrontMatter();
// normalize static routes and blog slugs to be absolute URLs
staticRoutes.forEach((route) => {
@@ -53,7 +55,7 @@ const sitemap = async (): Promise<MetadataRoute.Sitemap> => {
});
});
// sort alphabetically by URL, sometimes fast-glob returns results in a different order
// sort alphabetically by URL, sometimes glob returns results in a different order
routes.sort((a, b) => (a.url < b.url ? -1 : 1));
return routes;
+4 -3
View File
@@ -21,7 +21,7 @@ const parseableDate = z.string().refine((value) => !Number.isNaN(Date.parse(valu
});
const titleToHtml = async (title: string): Promise<string> => {
return unified()
const parsedTitle = await unified()
.use(remarkParse)
.use(remarkSmartypants)
.use(remarkRehype)
@@ -29,8 +29,9 @@ const titleToHtml = async (title: string): Promise<string> => {
tagNames: ["code", "em", "strong"],
})
.use(rehypeStringify)
.process(title)
.then((result) => result.toString().trim());
.process(title);
return parsedTitle.toString().trim();
};
const contentToFeedHtml = async (content: string): Promise<string> => {
+18 -20
View File
@@ -3,14 +3,14 @@ import { Feed, type Item as FeedItem } from "feed";
import ogImage from "@/app/opengraph-image.jpg";
import authorConfig from "@/lib/config/author";
import siteConfig from "@/lib/config/site";
import { getContent, getFrontMatter } from "@/lib/posts";
import { getPost, getFrontMatter } from "@/lib/posts";
/**
* Returns a `Feed` object, which can then be processed with `feed.rss2()`, `feed.atom1()`, or `feed.json1()`.
* @see https://github.com/jpmonette/feed#example
*/
export const buildFeed = async (): Promise<Feed> => {
const frontmatter = await getFrontMatter();
export const buildFeed = (): Feed => {
const frontmatter = getFrontMatter();
const feed = new Feed({
id: `${process.env.NEXT_PUBLIC_BASE_URL}`,
@@ -32,25 +32,23 @@ export const buildFeed = async (): Promise<Feed> => {
});
// parse posts into feed items
const posts: FeedItem[] = await Promise.all(
frontmatter.map(async (post) => ({
guid: post.permalink,
link: post.permalink,
title: post.title,
description: post.description,
author: [
{
name: authorConfig.name,
link: `${process.env.NEXT_PUBLIC_BASE_URL}`,
},
],
date: new Date(post.date),
content: `
${await getContent(post.slug)}
const posts: FeedItem[] = frontmatter.map((post) => ({
guid: post.permalink,
link: post.permalink,
title: post.title,
description: post.description,
author: [
{
name: authorConfig.name,
link: `${process.env.NEXT_PUBLIC_BASE_URL}`,
},
],
date: new Date(post.date),
content: `
${getPost(post.slug)?.feedHtml}
<p><a href="${post.permalink}"><strong>Continue reading...</strong></a></p>
`.trim(),
})),
);
}));
// sort posts reverse chronologically in case the promises resolved out of order
posts.sort((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime());
+9 -27
View File
@@ -17,38 +17,25 @@ export type Post = (typeof allPosts)[number];
/** Path to directory with .mdx files, relative to project root. */
export const POSTS_DIR = "notes" as const;
const sortPosts = (posts: Post[]): Post[] => {
return posts.toSorted(
(post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime(),
);
};
/** Use generated content collections data to get all post slugs. */
export const getSlugs = async (): Promise<string[]> => {
"use cache";
export const getSlugs = (): string[] => {
return allPosts.map((post) => post.slug);
};
export const getPost = async (slug: string): Promise<Post | undefined> => {
"use cache";
/** Returns the post for a given slug, or undefined if the slug does not exist. */
export const getPost = (slug: string): Post | undefined => {
return allPosts.find((post) => post.slug === slug);
};
/**
* Returns the front matter of ALL posts, sorted reverse chronologically.
*/
export async function getFrontMatter(): Promise<FrontMatter[]>;
export function getFrontMatter(): FrontMatter[];
/**
* Returns the front matter of a given slug, or undefined if the slug does not exist.
*/
export async function getFrontMatter(slug: string): Promise<FrontMatter | undefined>;
export async function getFrontMatter(
slug?: string,
): Promise<FrontMatter[] | FrontMatter | undefined> {
"use cache";
export function getFrontMatter(slug: string): FrontMatter | undefined;
export function getFrontMatter(slug?: string): FrontMatter[] | FrontMatter | undefined {
const toFrontMatter = (post: Post): FrontMatter => ({
slug: post.slug,
permalink: post.permalink,
@@ -67,15 +54,10 @@ export async function getFrontMatter(
}
if (!slug) {
return sortPosts(allPosts).map(toFrontMatter);
return allPosts
.toSorted((post1, post2) => new Date(post2.date).getTime() - new Date(post1.date).getTime())
.map(toFrontMatter);
}
throw new Error("getFrontMatter() called with invalid argument.");
}
/** Returns the sanitized HTML content of a post for RSS feeds. */
export const getContent = async (slug: string): Promise<string | undefined> => {
"use cache";
return allPosts.find((post) => post.slug === slug)?.feedHtml;
};
+7
View File
@@ -2,6 +2,7 @@ import type { NextConfig } from "next";
const nextConfig = {
cacheComponents: true,
partialPrefetching: true,
reactCompiler: true,
pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"],
images: {
@@ -17,6 +18,12 @@ const nextConfig = {
},
],
},
compiler: {
defineServer: {
// frozen timestamp to make things like the sitemap prerender/cache-friendly
"process.env.BUILD_TIME": new Date().toISOString(),
},
},
outputFileTracingIncludes: {
"/notes/[slug]/opengraph-image": [
"./notes/**/*",
+6 -6
View File
@@ -18,13 +18,13 @@
"lint:fix": "oxlint --fix",
"fmt": "oxfmt",
"fmt:check": "oxfmt --check",
"check-types": "content-collections build && tsc --noEmit",
"check-types": "next typegen && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@better-auth/drizzle-adapter": "1.7.0-rc.4",
"@better-auth/drizzle-adapter": "1.7.0-rc.6",
"@fontsource/inter": "^5.3.0",
"@mdx-js/loader": "^3.1.1",
"@mdx-js/react": "^3.1.1",
@@ -36,12 +36,11 @@
"@vercel/analytics": "^2.0.1",
"@vercel/functions": "^3.9.3",
"@vercel/speed-insights": "^2.0.0",
"better-auth": "1.7.0-rc.4",
"better-auth": "1.7.0-rc.6",
"cheerio": "^1.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-rc.5-ab785fc",
"fast-glob": "^3.3.3",
"feed": "^6.0.0",
"html-entities": "^2.6.0",
"next": "16.3.1",
@@ -77,6 +76,7 @@
"shiki": "^4.4.3",
"sonner": "^2.0.8",
"tailwind-merge": "^3.6.0",
"tinyglobby": "^0.2.17",
"unified": "^11.0.5",
"zod": "^4.4.3"
},
@@ -101,12 +101,12 @@
"schema-dts": "^2.0.0",
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3"
"typescript": "^7.0.2"
},
"engines": {
"node": ">=24.x"
},
"packageManager": "pnpm@11.21.0",
"packageManager": "pnpm@11.22.0",
"cacheDirectories": [
"node_modules",
".next/cache"
+360 -178
View File
File diff suppressed because it is too large Load Diff