Skip to content

Instantly share code, notes, and snippets.

@chaance
Created November 10, 2022 18:58
Show Gist options
  • Save chaance/36dec2c6688573661ca3939e6a519ab7 to your computer and use it in GitHub Desktop.
Save chaance/36dec2c6688573661ca3939e6a519ab7 to your computer and use it in GitHub Desktop.
Remix resource route for generating dynamic social images with Cloudinary transforms
import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import {
getSocialImageUrl,
getImageContentType,
} from "~/utils.server";
export async function loader({ request }: LoaderArgs) {
let requestUrl = new URL(request.url);
let searchParams = new URLSearchParams(requestUrl.search);
let siteUrl = requestUrl.protocol + "//" + requestUrl.host;
let slug = searchParams.get("slug");
let title = searchParams.get("title");
let authorName = searchParams.get("authorName");
let authorTitle = searchParams.get("authorTitle");
let displayDate = searchParams.get("date");
if (!slug || !title || !authorName || !authorTitle || !displayDate) {
throw json({ error: "Missing required params" }, 400);
}
let socialImageUrl = await getSocialImageUrl({
slug,
siteUrl,
title,
authorName,
authorTitle,
displayDate,
});
try {
let contentType = await getImageContentType(socialImageUrl);
if (!contentType) {
throw json({ error: "Invalid image" }, 400);
}
let resp = await fetch(socialImageUrl, {
headers: {
"Content-Type": contentType,
},
});
if (resp.status >= 300) {
throw resp;
}
return resp;
} catch (err) {
throw Error(
"Error fetching the social image; this is likely an error in the img/social route loader"
);
}
}
import path from "path";
import fsp from "fs/promises";
import getEmojiRegex from "emoji-regex";
import invariant from "ts-invariant";
const CLOUDINARY_CLOUD_NAME = process.env.CLOUDINARY_CLOUD_NAME;
const CLOUDINARY_FOLDER_NAME = process.env.CLOUDINARY_FOLDER_NAME;
invariant(
CLOUDINARY_CLOUD_NAME,
"process.env.CLOUDINARY_CLOUD_NAME must be set for generating social images"
);
invariant(
CLOUDINARY_FOLDER_NAME,
"process.env.CLOUDINARY_FOLDER_NAME must be set for generating social images"
);
function stripEmojis(string: string): string {
return string.replace(getEmojiRegex(), "").replace(/\s+/g, " ").trim();
}
// cloudinary needs double-encoding
function doubleEncode(s: string) {
return encodeURIComponent(encodeURIComponent(s));
}
function getCloudinarySocialImageUrl({
title,
displayDate,
authorName,
authorTitle,
}: SocialImageArgs) {
// Important: no leading hash for hex values
let primaryTextColor = "ffffff";
let secondaryTextColor = "d0d0d0";
// Custom font files — must be uploaded to cloudinary in the same folder as
// all of the images
let primaryFont = "Inter-Medium.woff2";
let titleFont = "Founders-Grotesk-Bold.woff2";
let vars = [
"$th_1256", // image height in pixels
"$tw_2400", // image width in pixels
"$gh_$th_div_12", // image height / 12
"$gw_$tw_div_24", // image width / 24
].join(",");
let templateImage = `${CLOUDINARY_FOLDER_NAME}/social-background.png`;
let templateImageTransform = [
"c_fill", // fit rule
"w_$tw", // width
"h_$th", // height
].join(",");
let encodedDate = doubleEncode(stripEmojis(displayDate));
let dateTransform = [
`co_rgb:${secondaryTextColor}`, // text color
"c_fit", // fit rule
"g_north_west", // text box position
"w_$gw_mul_14", // text box width ($gw * 14)
"h_$gh", // text box height
"x_$gw_mul_1.5", // text box x position ($gw * 1.5)
"y_$gh_mul_1.3", // text box y position ($gh * 1.3)
`l_text:${CLOUDINARY_FOLDER_NAME}:${primaryFont}_50_bold:${encodedDate}`, // textbox:font:content
].join(",");
let encodedTitle = doubleEncode(stripEmojis(title));
let titleTransform = [
`co_rgb:${primaryTextColor}`, // text color
"c_fit", // fit rule
"g_north_west", // text box position
"w_$gw_mul_18", // text box width ($gw * 20.5)
"h_$gh_mul_7", // text box height ($gh * 7)
"x_$gw_mul_1.5", // text box x position ($gw * 1.5)
"y_$gh_mul_2.3", // text box y position ($gh * 2.3)
`l_text:${CLOUDINARY_FOLDER_NAME}:${titleFont}_160:${encodedTitle}`, // textbox:font:content
].join(",");
let encodedAuthorName = doubleEncode(stripEmojis(authorName));
let authorNameSlug = slugify(stripEmojis(authorName));
let authorNameTransform = [
`co_rgb:${primaryTextColor}`, // text color
`c_fit`, // fit rule
`g_north_west`, // text box position
`w_$gw_mul_8.5`, // text box width ($gw * 5.5)
`h_$gh_mul_4`, // text box height ($gh * 4)
`x_$gw_mul_4.5`, // text box x position ($gw * 4.5)
`y_$gh_mul_9`, // text box y position ($gh * 9)
`l_text:${CLOUDINARY_FOLDER_NAME}:${primaryFont}_70:${encodedAuthorName}`, // textbox:font:content
].join(",");
let encodedAuthorTitle = doubleEncode(stripEmojis(authorTitle));
let authorTitleTransform = [
`co_rgb:${secondaryTextColor}`, // text color
`c_fit`, // fit rule
`g_north_west`, // text box position
`w_$gw_mul_9`, // text box width ($gw * 9)
`x_$gw_mul_4.5`, // text box x position ($gw * 4.5)
`y_$gh_mul_9.8`, // text box y position ($gh * 9.8)
`l_text:${CLOUDINARY_FOLDER_NAME}:${primaryFont}_40:${encodedAuthorTitle}`, // textbox:font:content
].join(",");
let authorImageTransform = [
"c_fit", // fit rule
"g_north_west", // image position
`r_max`, // image radius
"w_$gw_mul_4", // image width ($gw * 4)
"h_$gh_mul_3", // image height ($gh * 3)
"x_$gw", // image x position ($gw)
"y_$gh_mul_8", // image y position ($gh * 8)
`l_${CLOUDINARY_FOLDER_NAME}:profile-${authorNameSlug}.jpg`, // image filename
].join(",");
return [
`https://res.cloudinary.com/${CLOUDINARY_CLOUD_NAME}/image/upload`,
vars,
templateImageTransform,
dateTransform,
titleTransform,
authorImageTransform,
authorNameTransform,
authorTitleTransform,
templateImage,
].join("/");
}
export async function getSocialImageUrl({
slug,
siteUrl,
...socialImageArgs
}: { slug: string; siteUrl: string } & SocialImageArgs) {
let basePath = `blog-images/social/${slug}.jpg`;
let filePath = path.join(__dirname, "..", "public", basePath);
if (await fileExists(filePath)) {
return `${siteUrl}/${basePath}`;
}
return getCloudinarySocialImageUrl(socialImageArgs);
}
export async function getImageContentType(imagePath: string) {
let ext = path.extname(imagePath).toLowerCase();
if (!ext) return null;
return `image/${ext.slice(1)}`;
}
interface SocialImageArgs {
title: string;
displayDate: string;
authorName: string;
authorTitle: string;
}
async function fileExists(filePath: string) {
try {
let stat = await fsp.stat(filePath);
return stat.isFile();
} catch (_) {
return false;
}
}
function slugify(string: string) {
return string
.toLowerCase()
.replace(/[^a-z0-9\s_-]/g, "")
.trim()
.replace(/[\s_-]+/g, "-");
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment