Skip to content

Commit

Permalink
feat: adds support for Open Graph images
Browse files Browse the repository at this point in the history
  • Loading branch information
akoenig committed Apr 6, 2024
1 parent aef1a69 commit 2e6b47a
Show file tree
Hide file tree
Showing 9 changed files with 291 additions and 1 deletion.
1 change: 1 addition & 0 deletions app/routes/_frontend.$category.$slug.details/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const Workflow = z.object({
id: z.string(),
category: z.string(),
name: z.string(),
description: z.string(),
title: z.string(),
readme: z.string(),
installCommand: z.string(),
Expand Down
2 changes: 2 additions & 0 deletions app/routes/_frontend.$category.$slug.details/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export async function getModel(
id: currentWorkflowDao.id,
category: currentWorkflowDao.category,
name: currentWorkflowDao.name,
description: currentWorkflowDao.description,
title: currentWorkflowDao.title,
readme: currentWorkflowDao.readme,
installCommand: createInstallCommand(baseUrl, currentWorkflowDao.id),
Expand All @@ -35,6 +36,7 @@ export async function getModel(
id: dao.id,
category: dao.category,
name: dao.name,
description: dao.description,
title: dao.title,
readme: dao.readme,
installCommand: createInstallCommand(baseUrl, dao.id),
Expand Down
48 changes: 47 additions & 1 deletion app/routes/_frontend.$category.$slug.details/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,63 @@ import {
} from "~/components/ui/dropdown-menu";
import { WorkflowDetailCard } from "~/components/workflow-detail-card";
import { cn } from "~/utils/cn";
import { getBaseUrl } from "~/utils/get-base-url.server";
import { getModel } from "./query";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [
{
title: `GitHub Actions Starter Workflows: ${data?.currentWorkflow.title} - getactions.dev`,
},
{
name: "description",
content: data?.currentWorkflow.description,
},
{
property: "og:site_name",
content: "getactions.dev",
},
{
property: "og:type",
content: "article",
},
{
property: "og:title",
content: data?.currentWorkflow.title,
},
{
property: "og:description",
content: data?.currentWorkflow.description,
},
{
property: "og:url",
content: `${data?.baseUrl}/${data?.currentWorkflow.id}/details`,
},
{
property: "og:image",
content: `${data?.baseUrl}/api/${data?.currentWorkflow.id}.png`,
},
{
property: "twitter:card",
content: "summary_large_image",
},
{
property: "twitter:title",
content: data?.currentWorkflow.title,
},
{
property: "twitter:description",
content: data?.currentWorkflow.description,
},
{
property: "twitter:image",
content: `${data?.baseUrl}/api/${data?.currentWorkflow.id}.png`,
},
];
};

export async function loader({ params, request }: LoaderFunctionArgs) {
const baseUrl = getBaseUrl(request);
const category = String(params.category);
const slug = String(params.slug);

Expand All @@ -45,7 +91,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {

const model = result.value;

return json(model);
return json({ baseUrl, ...model });
}

export default function WorkflowDetails() {
Expand Down
8 changes: 8 additions & 0 deletions app/routes/api.$category.$slug[.png]/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { z } from "zod";

export const Model = z.object({
id: z.string(),
category: z.object({ id: z.string(), name: z.string() }),
title: z.string(),
installCommand: z.string(),
});
32 changes: 32 additions & 0 deletions app/routes/api.$category.$slug[.png]/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { findById, getCategories } from "#workflows";
import { err, ok } from "neverthrow";
import { getBaseUrl } from "~/utils/get-base-url.server";
import { Model } from "./model";

export async function getModel(
request: Request,
category: string,
slug: string,
) {
const baseUrl = getBaseUrl(request);
const id = `${category}/${slug}`;

const workflow = findById(id);

const installCommand = `curl -s ${baseUrl}/${id} | sh`;

const currentCatgory = getCategories().find((c) => c.id === category);

const model = Model.safeParse({
id,
category: currentCatgory,
title: workflow.title,
installCommand,
});

if (!model.success) {
return err(new Error(`failed to prepare view model: ${model.error}`));
}

return ok(model.data);
}
51 changes: 51 additions & 0 deletions app/routes/api.$category.$slug[.png]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import type { LoaderFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { makeGenerateOgImage } from "~/utils/create-og-image.server";
import { getModel } from "./query";

const ParamsSchema = z.object({
category: z.string(),
slug: z.string(),
});

export async function loader(args: LoaderFunctionArgs) {
const params = ParamsSchema.safeParse(args.params);

if (!params.success) {
throw new Response("Bad Request", { status: 400 });
}

const fetchModelResult = await getModel(
args.request,
params.data.category,
params.data.slug,
);

if (fetchModelResult.isErr()) {
console.error(fetchModelResult.error);

throw new Response("Internal Server Error", { status: 500 });
}

const model = fetchModelResult.value;

const createOgImage = makeGenerateOgImage();

const resultOfGeneratingImage = await createOgImage({
title: model.title,
subtitle: `GitHub Actions ${model.category.name} Starter Workflow`,
shell: model.installCommand,
});

if (resultOfGeneratingImage.isErr()) {
throw new Response("Internal Server Error", { status: 500 });
}

const { image } = resultOfGeneratingImage.value;

return new Response(image, {
headers: {
"Content-Type": "image/png",
},
});
}
148 changes: 148 additions & 0 deletions app/utils/create-og-image.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { SatoriOptions } from "satori";

import satori from "satori";

import { Resvg } from "@resvg/resvg-js";
import { err, ok } from "neverthrow";

type GenerateOgImageRequest = Readonly<{
title: string;
subtitle: string;
shell?: string;
}>;

// hypercolor gradients (https://hypercolor.dev)
const backgrounds = [
{
name: "Hypher",
css: "linear-gradient(to right top, rgb(236, 72, 153), rgb(239, 68, 68), rgb(234, 179, 8))",
},
{
name: "Midnight",
css: "linear-gradient(to right top, rgb(29, 78, 216), rgb(30, 64, 175), rgb(17, 24, 39))",
},
{
name: "Flamingo",
css: "linear-gradient(to right top, rgb(244, 114, 182), rgb(219, 39, 119))",
},
{
name: "Solid Blue",
css: "linear-gradient(to left, rgb(59, 130, 246), rgb(37, 99, 235))",
},
{
name: "Picture",
css: "linear-gradient(to left bottom, rgb(217, 70, 239), rgb(220, 38, 38), rgb(251, 146, 60))",
},
{
name: "Video",
css: "linear-gradient(to left top, rgb(239, 68, 68), rgb(153, 27, 27))",
},
{
name: "Pink Neon",
css: "linear-gradient(to right, rgb(192, 38, 211), rgb(219, 39, 119))",
},
{
name: "Purple Haze",
css: "linear-gradient(to left, rgb(107, 33, 168), rgb(76, 29, 149), rgb(107, 33, 168))",
},
{
name: "Emerald",
css: "linear-gradient(to right, rgb(16, 185, 129), rgb(101, 163, 13))",
},
{
name: "Salem",
css: "linear-gradient(to top, rgb(17, 24, 39), rgb(88, 28, 135), rgb(124, 58, 237))",
},
];

async function getFont(font: string, weights = [400, 500, 600, 700]) {
const css = await fetch(
`https://fonts.googleapis.com/css2?family=${font}:wght@${weights.join(
";",
)}`,
{
headers: {
// Make sure it returns TTF.
"User-Agent":
"Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_8; de-at) AppleWebKit/533.21.1 (KHTML, like Gecko) Version/5.0.5 Safari/533.21.1",
},
},
).then((response) => response.text());
const resource = css.matchAll(
/src: url\((.+)\) format\('(opentype|truetype)'\)/g,
);
return Promise.all(
[...resource]
.map((match) => match[1])
.map((url) => fetch(url).then((response) => response.arrayBuffer()))
.map(async (buffer, i) => ({
name: font,
style: "normal",
weight: weights[i],
data: await buffer,
})),
) as Promise<SatoriOptions["fonts"]>;
}

declare module "react" {
interface HTMLAttributes<T> {
tw?: string;
}
}

export function makeGenerateOgImage() {
// Random background gradient
const background =
backgrounds[Math.floor(Math.random() * backgrounds.length)];

return async (request: GenerateOgImageRequest) => {
try {
const template = (
<div
tw="h-full w-full flex items-start justify-start"
style={{
background: background.css,
}}
>
<div tw="flex items-start justify-start h-full">
<div tw="flex flex-col justify-between w-full h-full p-4">
<div tw="flex flex-col bg-white/70 h-full rounded-3xl justify-center">
<h1 tw="flex justify-center text-3xl font-extrabold text-center tracking-[-2px] leading-none px-8">
get
<span tw="text-[#e11d48]">actions</span>.dev
</h1>

<h2 tw="flex justify-center text-7xl font-extrabold opacity-90 tracking-tight leading-10">
{request.title}
</h2>
<p tw="flex justify-center text-3xl font-medium">
{request.subtitle}
</p>

{request.shell ? (
<div tw="flex bg-white justify-center mt-8 mx-38 rounded-2xl border-[#e11d48]">
<pre tw="text-xl font-mono">{request.shell}</pre>
</div>
) : null}
</div>
</div>
</div>
</div>
);

const svg = await satori(template, {
width: 1200,
height: 630,
fonts: await getFont("Inter"),
});

const resvg = new Resvg(svg);
const pngData = resvg.render();
const image = pngData.asPng();

return ok({ image });
} catch (cause) {
return err(cause instanceof Error ? cause : new Error(cause as string));
}
};
}
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1",
"@resvg/resvg-js": "^2.6.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"common-tags": "^1.8.2",
Expand All @@ -29,6 +30,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-markdown": "^9.0.1",
"satori": "^0.10.13",
"tailwind-merge": "^2.2.2",
"tailwindcss-animate": "^1.0.7",
"usehooks-ts": "^3.0.2",
Expand Down

0 comments on commit 2e6b47a

Please sign in to comment.