mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2026-08-31 08:15:34 -04:00
fix: make sitemap.xml prerenderable
This commit is contained in:
@@ -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,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: {
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Generated
+360
-178
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user