mirror of
https://github.com/jakejarvis/jarv.is.git
synced 2025-04-26 03:05:24 -04:00
prettier dynamic opengraph images 💅
This commit is contained in:
parent
a6d4056947
commit
e67d49f430
@ -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,
|
||||||
}),
|
}),
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
},
|
},
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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}`
|
||||||
|
@ -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 */
|
||||||
|
Loading…
x
Reference in New Issue
Block a user