1
mirror of https://github.com/jakejarvis/jarv.is.git synced 2025-04-26 07:45:25 -04:00

prettier dynamic opengraph images 💅

This commit is contained in:
Jake Jarvis 2025-04-16 15:00:02 -04:00
parent a6d4056947
commit e67d49f430
Signed by: jake
SSH Key Fingerprint: SHA256:nCkvAjYA6XaSPUqc4TfbBQTpzr8Xj7ritg/sGInCdkc
7 changed files with 99 additions and 81 deletions

View File

@ -72,7 +72,7 @@ export const send = async (state: ContactState, payload: FormData): Promise<Cont
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
secret: env.TURNSTILE_SECRET_KEY || "1x0000000000000000000000000000000AA", secret: env.TURNSTILE_SECRET_KEY,
response: data.output["cf-turnstile-response"], response: data.output["cf-turnstile-response"],
remoteip, remoteip,
}), }),

View File

@ -105,7 +105,7 @@ const ContactForm = () => {
</div> </div>
<div style={{ margin: "1em 0" }}> <div style={{ margin: "1em 0" }}>
<Turnstile sitekey={env.NEXT_PUBLIC_TURNSTILE_SITE_KEY || "1x00000000000000000000AA"} fixedSize /> <Turnstile sitekey={env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} fixedSize />
</div> </div>
{!pending && formState.errors?.["cf-turnstile-response"] && ( {!pending && formState.errors?.["cf-turnstile-response"] && (
<span className={styles.errorMessage}>{formState.errors["cf-turnstile-response"][0]}</span> <span className={styles.errorMessage}>{formState.errors["cf-turnstile-response"][0]}</span>

View File

@ -1,10 +1,11 @@
import { env } from "../../../lib/env";
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { join } from "path"; import path from "path";
import { existsSync } from "fs"; import fs from "fs";
import { readFile } from "fs/promises";
import { getSlugs, getFrontMatter } from "../../../lib/helpers/posts"; import { getSlugs, getFrontMatter } from "../../../lib/helpers/posts";
import { POSTS_DIR, AVATAR_PATH } from "../../../lib/config/constants"; import * as config from "../../../lib/config";
import { POSTS_DIR } from "../../../lib/config/constants";
export const contentType = "image/png"; export const contentType = "image/png";
export const size = { export const size = {
@ -29,10 +30,10 @@ 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 // 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 NO_IMAGE = "data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=";
const imagePath = join(process.cwd(), src); const imagePath = path.join(process.cwd(), src);
try { try {
if (!existsSync(imagePath)) { if (!fs.existsSync(imagePath)) {
console.error(`[og-image] couldn't find an image file located at "${imagePath}"`); console.error(`[og-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 a 1x1 transparent gif if the image doesn't exist instead of crashing
@ -40,7 +41,7 @@ const getLocalImage = async (src: string): Promise<ArrayBuffer | string> => {
} }
// return the raw image data as a buffer // return the raw image data as a buffer
return Uint8Array.from(await readFile(imagePath)).buffer; return Uint8Array.from(await fs.promises.readFile(imagePath)).buffer;
} catch (error) { } catch (error) {
console.error(`[og-image] found "${imagePath}" but couldn't read it:`, error); console.error(`[og-image] found "${imagePath}" but couldn't read it:`, error);
@ -56,6 +57,16 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
// get the post's title and image filename from its frontmatter // get the post's title and image filename from its frontmatter
const frontmatter = await getFrontMatter(slug); const frontmatter = await getFrontMatter(slug);
const [postImg, avatarImg, fontRegular, fontSemiBold] = await Promise.all([
frontmatter!.image ? getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter!.image}`) : null,
// IMPORTANT: include these exact paths in next.config.ts under "outputFileTracingIncludes"
getLocalImage("app/avatar.jpg"),
// load the Geist font directly from its npm package
fs.promises.readFile(path.join(process.cwd(), "node_modules/geist/dist/fonts/geist-sans/Geist-Regular.ttf")),
fs.promises.readFile(path.join(process.cwd(), "node_modules/geist/dist/fonts/geist-sans/Geist-SemiBold.ttf")),
]);
// template is HEAVILY inspired by https://og-new.clerkstage.dev/ // template is HEAVILY inspired by https://og-new.clerkstage.dev/
return new ImageResponse( return new ImageResponse(
( (
@ -91,25 +102,6 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
}} }}
></div> ></div>
<div
style={{
display: "flex",
paddingTop: "2rem",
paddingLeft: "2rem",
}}
>
<img
// @ts-expect-error
src={await getLocalImage(AVATAR_PATH)}
alt=""
style={{
width: "3rem",
height: "3rem",
borderRadius: "0.75rem",
}}
/>
</div>
<div <div
style={{ style={{
display: "flex", display: "flex",
@ -124,33 +116,41 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
flexDirection: "column", flexDirection: "column",
rowGap: "1.5rem", rowGap: "1.5rem",
flexShrink: 0, flexShrink: 0,
paddingTop: "2.5rem", paddingTop: "2rem",
// don't wrap the title text at 50% if there's no image to leave room for // don't wrap the title text if there's no image to leave room for
width: frontmatter!.image ? "50%" : "100%", width: postImg ? "35%" : "100%",
marginRight: "0.75rem",
}} }}
> >
<div <div
style={{ style={{
display: "flex", display: "flex",
flexGrow: 0, marginBottom: "0.75rem",
}} }}
> >
{avatarImg && (
<img
// @ts-expect-error
src={avatarImg}
alt=""
style={{
width: "3rem",
height: "3rem",
borderRadius: "50%",
}}
/>
)}
<span <span
style={{ style={{
fontFamily: "Geist-Regular", fontSize: "1.825rem",
fontWeight: 400, fontFamily: "Geist-SemiBold",
fontSize: "20px", fontWeight: 700,
color: "#030712", lineHeight: "3rem",
border: "solid", letterSpacing: "-0.015em",
borderRadius: "100", marginLeft: "0.75rem",
borderWidth: "2px",
paddingRight: "16px",
paddingLeft: "16px",
paddingTop: "5px",
paddingBottom: "5px",
}} }}
> >
Notes {config.siteName}
</span> </span>
</div> </div>
@ -169,6 +169,31 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
{frontmatter!.title} {frontmatter!.title}
</div> </div>
<div
style={{
display: "flex",
flexGrow: 0,
}}
>
<span
style={{
fontFamily: "Geist-Regular",
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 <div
style={{ style={{
display: "flex", display: "flex",
@ -181,7 +206,7 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
lineHeight: "1.2", lineHeight: "1.2",
}} }}
> >
{new Date(frontmatter!.date).toLocaleDateString("en-US", { {new Date(frontmatter!.date).toLocaleDateString(env.NEXT_PUBLIC_SITE_LOCALE, {
year: "numeric", year: "numeric",
month: "long", month: "long",
day: "numeric", day: "numeric",
@ -189,19 +214,23 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
</div> </div>
</div> </div>
{frontmatter!.image && ( {postImg && (
<div <div
style={{ style={{
display: "flex", display: "flex",
width: "100%", // only 50% in reality, but this gives the image the overflow look width: "100%", // less than half in reality, but this gives the image the overflow look
flexGrow: 0, flexGrow: 0,
}} }}
> >
<img <img
// @ts-expect-error // @ts-expect-error
src={await getLocalImage(`${POSTS_DIR}/${slug}/${frontmatter!.image}`)} src={postImg}
alt="" alt=""
style={{ borderRadius: "0.75rem" }} style={{
maxHeight: "100%",
minHeight: 630,
width: "auto",
}}
/> />
</div> </div>
)} )}
@ -213,15 +242,13 @@ const OpenGraphImage = async ({ params }: { params: Promise<{ slug: string }> })
fonts: [ fonts: [
{ {
name: "Geist-Regular", name: "Geist-Regular",
// load the Geist font directly from its npm package data: fontRegular,
// IMPORTANT: include this exact path in next.config.ts under "outputFileTracingIncludes"
data: await readFile(join(process.cwd(), "node_modules/geist/dist/fonts/geist-sans/Geist-Regular.ttf")),
style: "normal", style: "normal",
weight: 400, weight: 400,
}, },
{ {
name: "Geist-SemiBold", name: "Geist-SemiBold",
data: await readFile(join(process.cwd(), "node_modules/geist/dist/fonts/geist-sans/Geist-SemiBold.ttf")), data: fontSemiBold,
style: "normal", style: "normal",
weight: 700, weight: 700,
}, },

View File

@ -2,11 +2,12 @@
* Inline code * Inline code
**/ **/
:not([data-rehype-pretty-code-figure]) .code { .code {
padding: 0.2em 0.3em; padding: 0.2em 0.3em;
font-size: 0.925em; font-size: 0.925em;
tab-size: 2px;
page-break-inside: avoid; page-break-inside: avoid;
background-color: var(--colors-background-outer); background-color: var(--colors-background-header);
border: 1px solid var(--colors-kinda-light); border: 1px solid var(--colors-kinda-light);
border-radius: 0.6em; border-radius: 0.6em;
} }
@ -15,45 +16,41 @@
* Syntax-highlighted code blocks * Syntax-highlighted code blocks
**/ **/
[data-rehype-pretty-code-figure]:has(.code) { figure:has(.code) {
margin: 1em auto; margin: 1em 0;
position: relative; position: relative;
width: 100%; width: 100%;
background-color: var(--colors-background-header); border-radius: 0.6em;
} }
[data-rehype-pretty-code-figure] .code { figure .code {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
padding: 1em; padding: 1em;
font-size: 0.9em;
tab-size: 2px;
border: 1px solid var(--colors-kinda-light);
border-radius: 0.6em;
counter-reset: line; counter-reset: line;
} }
[data-rehype-pretty-code-figure] .code [style*="--shiki"] { figure .code [style*="--shiki"] {
color: var(--shiki-light); color: var(--shiki-light);
font-style: var(--shiki-light-font-style); font-style: var(--shiki-light-font-style);
font-weight: var(--shiki-light-font-weight); font-weight: var(--shiki-light-font-weight);
text-decoration: var(--shiki-light-text-decoration); text-decoration: var(--shiki-light-text-decoration);
} }
[data-theme="dark"] [data-rehype-pretty-code-figure] .code [style*="--shiki"] { [data-theme="dark"] figure .code [style*="--shiki"] {
color: var(--shiki-dark); color: var(--shiki-dark);
font-style: var(--shiki-dark-font-style); font-style: var(--shiki-dark-font-style);
font-weight: var(--shiki-dark-font-weight); font-weight: var(--shiki-dark-font-weight);
text-decoration: var(--shiki-dark-text-decoration); text-decoration: var(--shiki-dark-text-decoration);
} }
[data-rehype-pretty-code-figure] .code[data-line-numbers] > [data-line]:nth-of-type(1), figure .code > [data-line]:nth-of-type(1),
[data-rehype-pretty-code-figure] .code[data-line-numbers] > [data-line]:nth-of-type(2) { figure .code > [data-line]:nth-of-type(2) {
/* excessive right padding to prevent copy button from covering the first two lines of code */ /* excessive right padding to prevent copy button from covering the first two lines of code */
padding-right: 4em; padding-right: 4em;
} }
[data-rehype-pretty-code-figure] .code[data-line-numbers] > [data-line]::before { figure .code[data-line-numbers] > [data-line]::before {
display: inline-block; display: inline-block;
width: 1em; width: 1em;
margin-right: 1.5em; margin-right: 1.5em;
@ -64,11 +61,11 @@
content: counter(line); content: counter(line);
} }
[data-rehype-pretty-code-figure] .code[data-line-numbers-max-digits="2"] > [data-line]::before { figure .code[data-line-numbers-max-digits="2"] > [data-line]::before {
width: 1.25rem; width: 1.25rem;
} }
[data-rehype-pretty-code-figure] .code[data-line-numbers-max-digits="3"] > [data-line]::before { figure .code[data-line-numbers-max-digits="3"] > [data-line]::before {
width: 1.75rem; width: 1.75rem;
} }

View File

@ -1,11 +1,5 @@
/** Path to directory with .mdx files, relative to project root. */ /** Path to directory with .mdx files, relative to project root. */
export const POSTS_DIR = "notes"; export const POSTS_DIR = "notes";
/**
* Path to an image used in various places to represent the site, relative to project root. This path must be included
* in [next.config.ts](../../next.config.ts) under `outputFileTracingIncludes`.
*/
export const AVATAR_PATH = "app/avatar.jpg";
/** Maximum width of content wrapper (e.g. for images) in pixels. */ /** Maximum width of content wrapper (e.g. for images) in pixels. */
export const MAX_WIDTH = 865; export const MAX_WIDTH = 865;

View File

@ -112,16 +112,16 @@ export const env = createEnv({
* *
* @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/ * @see https://developers.cloudflare.com/turnstile/troubleshooting/testing/
*/ */
NEXT_PUBLIC_TURNSTILE_SITE_KEY: v.optional(v.string(), "XXXX.DUMMY.TOKEN.XXXX"), NEXT_PUBLIC_TURNSTILE_SITE_KEY: v.optional(v.string(), "1x00000000000000000000AA"),
}, },
experimental__runtimeEnv: { experimental__runtimeEnv: {
NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL:
process.env.NEXT_PUBLIC_BASE_URL || process.env.NEXT_PUBLIC_BASE_URL ||
// Vercel: https://vercel.com/docs/environment-variables/system-environment-variables // Vercel: https://vercel.com/docs/environment-variables/system-environment-variables
(process.env.VERCEL (process.env.VERCEL
? process.env.VERCEL_ENV === "production" && process.env.VERCEL_PROJECT_PRODUCTION_URL ? process.env.VERCEL_ENV === "production"
? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
: process.env.VERCEL_ENV === "preview" && process.env.VERCEL_BRANCH_URL : process.env.VERCEL_ENV === "preview"
? `https://${process.env.VERCEL_BRANCH_URL}` ? `https://${process.env.VERCEL_BRANCH_URL}`
: process.env.VERCEL_URL : process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}` ? `https://${process.env.VERCEL_URL}`
@ -129,7 +129,7 @@ export const env = createEnv({
: undefined) || : undefined) ||
// Netlify: https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables // Netlify: https://docs.netlify.com/configure-builds/environment-variables/#read-only-variables
(process.env.NETLIFY (process.env.NETLIFY
? process.env.CONTEXT === "production" && process.env.URL ? process.env.CONTEXT === "production"
? `${process.env.URL}` ? `${process.env.URL}`
: process.env.DEPLOY_PRIME_URL : process.env.DEPLOY_PRIME_URL
? `${process.env.DEPLOY_PRIME_URL}` ? `${process.env.DEPLOY_PRIME_URL}`

View File

@ -97,7 +97,7 @@ Promise<any> => {
) as FrontMatter[]; ) as FrontMatter[];
} }
throw new Error(`getFrontMatter() called with invalid argument.`); throw new Error("getFrontMatter() called with invalid argument.");
}; };
/** Returns the content of a post with very limited processing to include in RSS feeds */ /** Returns the content of a post with very limited processing to include in RSS feeds */