1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2026-06-05 20:15:31 -04:00
Files
jarv.is/app/notes/[slug]/opengraph-image.tsx
T
jake e59aee63c5 fix: load OG image fonts from @fontsource/inter instead of Google Fonts
Vercel's build infra was intermittently hitting ETIMEDOUT against
fonts.googleapis.com, causing OG image generation errors during
prerender. Ship the Inter .woff files with the function via
outputFileTracingIncludes so the build is network-free. Also add
turbopackIgnore hints on process.cwd() calls to silence an NFT warning
that was over-tracing next.config.ts into the route bundle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:44:40 -04:00

279 lines
10 KiB
TypeScript

import fs from "node:fs";
import path from "node:path";
import { notFound } from "next/navigation";
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> => {
const fontPath = path.join(
/* turbopackIgnore: true */ process.cwd(),
"node_modules/@fontsource/inter/files",
`inter-latin-${weight}-normal.woff`,
);
const buffer = await fs.promises.readFile(fontPath);
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> => {
// 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=";
const imagePath = path.join(/* turbopackIgnore: true */ process.cwd(), src);
try {
if (!fs.existsSync(imagePath)) {
console.error(
`[/notes/[slug]/opengraph-image] couldn't find an image file located at "${imagePath}"`,
);
// return a 1x1 transparent gif if the image doesn't exist instead of crashing
return NO_IMAGE;
}
// return the raw image data as a buffer
return Uint8Array.from(await fs.promises.readFile(imagePath)).buffer;
} catch (error) {
console.error(
`[/notes/[slug]/opengraph-image] found "${imagePath}" but couldn't read it:`,
error,
);
// fail silently and return a 1x1 transparent gif instead of crashing
return NO_IMAGE;
}
};
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);
if (!frontmatter) notFound();
// IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes"
const [postImg, avatarImg] = await Promise.all([
frontmatter.image ? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter.image}`) : null,
getLocalImage("app/avatar.jpg"),
]);
const [fontRegular, fontSemibold] = await Promise.all([loadInterFont(400), loadInterFont(600)]);
// template is HEAVILY inspired by https://og-new.clerkstage.dev/
return new ImageResponse(
<div
style={{
...size,
display: "flex",
flexDirection: "column",
background:
"linear-gradient(to top right, rgb(134, 239, 172), rgb(59, 130, 246), rgb(147, 51, 234))",
}}
>
<div
style={{
height: "100%",
width: "100%",
position: "absolute",
inset: 0,
filter: "brightness(100%) contrast(150%)",
opacity: "0.1",
backgroundImage: `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="500" height="500"><filter id="noise" x="0" y="0"><feTurbulence type="fractalNoise" baseFrequency="0.65" numOctaves="3" stitchTiles="stitch"/><feBlend mode="screen"/></filter><rect width="500" height="500" filter="url(#noise)" opacity="1"/></svg>')`,
backgroundRepeat: "repeat",
}}
></div>
<div
style={{
height: "100%",
width: "100%",
position: "absolute",
opacity: "0.4",
backgroundImage: `url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><g fill-rule="evenodd" fill="#6b7280" fill-opacity="0.4"><g><path opacity="0.5" d="M96 95h4v1h-4v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4h-9v4h-1v-4H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15v-9H0v-1h15V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h9V0h1v15h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9h4v1h-4v9zm-1 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm9-10v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-10 0v-9h-9v9h9zm-9-10h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9zm10 0h9v-9h-9v9z"/><path d="M6 5V0H5v5H0v1h5v94h1V6h94V5H6z"/></g></g></svg>')`,
maskImage: "radial-gradient(rgb(0, 0, 0) 0%, rgba(0, 0, 0, 0) 80%)",
}}
></div>
<div
style={{
display: "flex",
width: "100%",
gap: "1.5rem",
paddingLeft: "2rem",
}}
>
<div
style={{
display: "flex",
flexDirection: "column",
rowGap: "1.5rem",
flexShrink: 0,
paddingTop: "2rem",
// don't wrap the title text if there's no image to leave room for
width: postImg ? "35%" : "100%",
marginRight: "0.75rem",
}}
>
<div
style={{
display: "flex",
marginBottom: "0.75rem",
}}
>
{avatarImg && (
// oxlint-disable-next-line nextjs/no-img-element - Satori/ImageResponse requires raw <img> tags
<img
// @ts-expect-error
src={avatarImg}
alt=""
style={{
width: "3rem",
height: "3rem",
borderRadius: "50%",
}}
/>
)}
<span
style={{
fontSize: "1.825rem",
fontWeight: 600,
lineHeight: "3rem",
letterSpacing: "-0.015em",
marginLeft: "0.75rem",
}}
>
{siteConfig.name}
</span>
</div>
<div
style={{
display: "flex",
flexGrow: 0,
fontWeight: 600,
fontSize: "48px",
color: "#030712",
letterSpacing: "-0.025em",
lineHeight: "1.2",
}}
>
{frontmatter.title}
</div>
<div
style={{
display: "flex",
flexGrow: 0,
}}
>
<span
style={{
fontWeight: 400,
fontSize: "20px",
color: "#030712",
border: "solid",
borderRadius: "100",
borderWidth: "2px",
paddingRight: "16px",
paddingLeft: "16px",
paddingTop: "5px",
paddingBottom: "5px",
}}
>
{POSTS_DIR.charAt(0).toUpperCase() + POSTS_DIR.slice(1)}
</span>
</div>
<div
style={{
display: "flex",
flexGrow: 0,
fontWeight: 400,
fontSize: "24px",
color: "#030712",
letterSpacing: "-0.025em",
lineHeight: "1.2",
}}
>
{new Date(frontmatter.date).toLocaleDateString(process.env.NEXT_PUBLIC_SITE_LOCALE, {
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
</div>
{postImg && (
<div
style={{
display: "flex",
width: "100%", // less than half in reality, but this gives the image the overflow look
flexGrow: 0,
}}
>
{/* oxlint-disable-next-line nextjs/no-img-element - Satori/ImageResponse requires raw <img> tags */}
<img
// @ts-expect-error
src={postImg}
alt=""
style={{
maxHeight: "100%",
minHeight: 630,
width: "auto",
}}
/>
</div>
)}
</div>
</div>,
{
...size,
fonts: [
{
name: "Inter",
data: fontRegular,
style: "normal",
weight: 400,
},
{
name: "Inter",
data: fontSemibold,
style: "normal",
weight: 600,
},
],
},
);
} catch (error) {
console.error("[/notes/[slug]/opengraph-image] error generating open graph image:", error);
notFound();
}
};
export default OpenGraphImage;