Skip to content

Commit

Permalink
Test og images (#801)
Browse files Browse the repository at this point in the history
  • Loading branch information
dimaMachina authored Feb 10, 2023
1 parent 0937a46 commit 893345e
Show file tree
Hide file tree
Showing 19 changed files with 1,292 additions and 454 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,8 @@ jobs:
- name: Lint ESLint
run: pnpm lint

- name: Test
run: pnpm test

- name: Build Storybook
run: pnpm build-storybook
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"prettier": "prettier --cache --write --list-different .",
"release": "changeset publish",
"start": "storybook dev --port 4000",
"test": "vitest .",
"type-check": "tsc",
"type-check:watch": "tsc --watch"
},
Expand All @@ -35,6 +36,7 @@
"@theguild/eslint-config": "0.8.0",
"@theguild/prettier-config": "1.1.1",
"@theguild/tailwind-config": "0.2.1",
"@types/jest-image-snapshot": "6.1.0",
"@types/react": "18.0.27",
"@types/react-instantsearch-core": "6.26.3",
"@types/react-instantsearch-dom": "6.12.3",
Expand All @@ -46,6 +48,7 @@
"eslint-plugin-storybook": "0.6.10",
"eslint-plugin-tailwindcss": "3.8.3",
"husky": "8.0.3",
"jest-image-snapshot": "6.1.0",
"lint-staged": "13.1.1",
"next-themes": "0.2.1",
"postcss": "8.4.21",
Expand All @@ -64,7 +67,8 @@
"tsconfig-paths-webpack-plugin": "4.0.0",
"tsup": "6.5.0",
"turbo": "1.7.4",
"typescript": "4.9.5"
"typescript": "4.9.5",
"vitest": "0.28.4"
},
"browserslist": [
"> 1%"
Expand Down
21 changes: 21 additions & 0 deletions packages/og-image/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// interface CustomMatchers<R = unknown> {
// toMatchImageSnapshot(): R;
// }

// declare global {
// namespace Vi {
// type Assertion = CustomMatchers;
//
// type AsymmetricMatchersContaining = CustomMatchers;
// }
//
// // Note: augmenting jest.Matchers interface will also work.
// }

declare global {
namespace jest {
interface Matchers<R> {
toMatchImageSnapshot(): R;
}
}
}
6 changes: 2 additions & 4 deletions packages/og-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,13 @@
"private": true,
"scripts": {
"deploy": "wrangler publish",
"postinstall": "mkdir -p src/vender && curl -L 'https://unpkg.com/yoga-wasm-web/dist/yoga.wasm' -o src/vender/yoga.wasm && curl -L 'https://unpkg.com/@resvg/resvg-wasm/index_bg.wasm' -o src/vender/resvg.wasm",
"start": "wrangler dev"
},
"dependencies": {
"@resvg/resvg-wasm": "2.3.1",
"@resvg/resvg-js": "2.4.0",
"@theguild/components": "workspace:*",
"react": "18.2.0",
"satori": "0.2.2",
"yoga-wasm-web": "0.3.0"
"satori": "0.2.3"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20230115.0",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
154 changes: 154 additions & 0 deletions packages/og-image/src/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { ComponentProps } from 'react';
import { shade } from './utils';

type CircleProps = ComponentProps<'svg'> & { color?: string; tw?: string };

export function RightSmallCircle({ color = '#f25c40', ...props }: CircleProps) {
return (
<svg
width="310"
height="316"
viewBox="0 0 310 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g filter="url(#filter0_b_1686_12556)">
<circle cx="158" cy="158" r="158" fill="url(#paint0_linear_1686_12556)" fillOpacity="0.4" />
<circle cx="158" cy="158" r="156" stroke="url(#paint1_linear_1686_12556)" strokeWidth="4" />
</g>
<defs>
<filter
id="filter0_b_1686_12556"
x="-94"
y="-94"
width="504"
height="504"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feGaussianBlur in="BackgroundImageFix" stdDeviation="47" />
<feComposite in2="SourceAlpha" operator="in" result="effect1_backgroundBlur_1686_12556" />
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_backgroundBlur_1686_12556"
result="shape"
/>
</filter>
<linearGradient
id="paint0_linear_1686_12556"
x1="124.485"
y1="131.667"
x2="345.661"
y2="295.327"
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color} />
<stop offset="1" stopColor={shade(color, -50)} />
</linearGradient>
<linearGradient
id="paint1_linear_1686_12556"
x1="124.485"
y1="131.667"
x2="158"
y2="267.5"
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color} />
<stop offset="1" stopColor={shade(color, -50)} stopOpacity="0" />
</linearGradient>
</defs>
</svg>
);
}

export function RightCircle({ color = '#7433ff', ...props }: CircleProps) {
return (
<svg
width="205"
height="616"
viewBox="0 0 205 616"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle cx="308" cy="308" r="308" fill="url(#paint0_linear_1686_12554)" />
<defs>
<linearGradient
id="paint0_linear_1686_12554"
x1="0"
y1="0"
x2="742.578"
y2="337.493"
gradientUnits="userSpaceOnUse"
>
<stop stopColor={shade(color, -50)} />
<stop offset="1" stopColor={color} />
</linearGradient>
</defs>
</svg>
);
}

export function LeftCircle({ color = '#1cc8ee', ...props }: CircleProps) {
return (
<svg
width="572"
height="584"
viewBox="0 0 572 584"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g opacity="0.4" filter="url(#filter0_f_2101_15208)">
<circle cx="70" cy="81.9999" r="308" fill="url(#paint0_linear_2101_15208)" />
</g>
<circle
cx="48"
cy="17"
r="308"
transform="rotate(-57.2911 48 17)"
fill="url(#paint1_linear_2101_15208)"
/>
<defs>
<filter
id="filter0_f_2101_15208"
x="-432"
y="-420"
width="1004"
height="1004"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape" />
<feGaussianBlur stdDeviation="97" result="effect1_foregroundBlur_2101_15208" />
</filter>
<linearGradient
id="paint0_linear_2101_15208"
x1="-307.41"
y1="-247.818"
x2="617.862"
y2="682.861"
gradientUnits="userSpaceOnUse"
>
<stop stopColor={shade(color, 50)} />
<stop offset="1" stopColor={shade(color, 100)} />
</linearGradient>
<linearGradient
id="paint1_linear_2101_15208"
x1="-230.791"
y1="17.773"
x2="659.231"
y2="554.86"
gradientUnits="userSpaceOnUse"
>
<stop stopColor={color} />
<stop offset="1" stopColor={shade(color, 100)} />
</linearGradient>
</defs>
</svg>
);
}
11 changes: 11 additions & 0 deletions packages/og-image/src/handler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { handler } from './handler';

describe('handler()', () => {
it('should works', async () => {
const response = await handler({
url: 'http://localhost:3000?product=CONDUCTOR',
});
const result = Buffer.from(await response.arrayBuffer());
expect(result).toMatchImageSnapshot();
});
});
71 changes: 71 additions & 0 deletions packages/og-image/src/handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
import { GuildLogo, TheGuild } from '@theguild/components/logos';
import { PRODUCTS } from '@theguild/components/products';
import { LeftCircle, RightCircle, RightSmallCircle } from './components';
import { shade, toImage, toSVG } from './utils';

const { WHATSAPP: _, HELIX: _2, ...filteredProducts } = PRODUCTS;

const products = {
...filteredProducts,
GUILD: {
name: 'The Guild',
logo: GuildLogo,
primaryColor: undefined,
},
};

const englishJoinWords = (words: string[]): string =>
new Intl.ListFormat('en-US', { type: 'disjunction' }).format(words);

const ALLOWED_PRODUCT_NAMES = englishJoinWords(Object.keys(products));

export async function handler(request: Request): Promise<Response> {
try {
const { searchParams } = new URL(request.url);
const productName = searchParams.get('product') as keyof typeof products | null;
const product = productName && products[productName];

if (!product) {
throw new Error(
`Unknown product name "${productName}".\nAllowed product names: ${ALLOWED_PRODUCT_NAMES}`,
);
}
// ?title=<title>
const title = searchParams.get('title')?.slice(0, 100);
const extra = searchParams.get('extra');
const IS_GUILD = productName === 'GUILD';

const rawSvg = await toSVG(
<div tw="flex bg-neutral-900 h-full flex-col w-full items-center justify-center">
<LeftCircle tw="absolute left-0 top-0" color={product.primaryColor} />
<RightCircle tw="absolute right-0" color={product.primaryColor} />
<RightSmallCircle
tw="absolute right-0 opacity-80"
color={shade(product.primaryColor || '', 100)}
/>
<product.logo style={{ transform: 'scale(2.5)' }} {...(IS_GUILD && { fill: 'white' })} />
<span tw="font-bold text-7xl text-white my-14 mb-10">{product.name}</span>
{title && <span tw="font-bold text-5xl text-white mb-4">{title}</span>}
{extra && <span tw="font-bold text-2xl text-white">{extra}</span>}
{!IS_GUILD && (
<div tw="flex items-center mt-14">
{/* @ts-expect-error -- using `tw` is valid with satori */}
<GuildLogo fill="#fff" tw="mr-1.5" />
<TheGuild fill="#fff" />
</div>
)}
</div>,
);

const buffer = toImage(rawSvg);

return new Response(buffer, {
headers: { 'Content-Type': 'image/png' },
});
} catch (e) {
return new Response(`Failed to generate the image.\n\nError: ${(e as Error).message}`, {
status: 500,
});
}
}
44 changes: 44 additions & 0 deletions packages/og-image/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-disable import/no-default-export */
/* eslint react/no-unknown-property: ['error', { ignore: ['tw'] }] */
import { handler } from './handler';

const hour = 3600;
const day = hour * 24;
const year = 365 * day;

const maxAgeForCDN = year;
const maxAgeForBrowser = hour / 2;

export default {
async fetch(request: Request, _env: unknown, ctx: ExecutionContext) {
const cacheUrl = new URL(request.url);

// In case you want to purge the cache, please bump the version number below:
cacheUrl.searchParams.set('version', 'v2');

// Construct the cache key from the cache URL
const cacheKey = new Request(cacheUrl.toString(), request);
const cache = caches.default;

let response = await cache.match(cacheKey);

if (!response) {
// If not in cache, get it from origin
response = await handler(request);

// Must use Response constructor to inherit all of response's fields
response = new Response(response.body, response);

// Any changes made to the response here will be reflected in the cached value
response.headers.append('Cache-Control', 'public');
response.headers.append('Cache-Control', `s-maxage=${maxAgeForCDN}`);
response.headers.append('Cache-Control', `max-age=${maxAgeForBrowser}`);

// Store the fetched response as cacheKey
// Use `waitUntil`, so you can return the response without blocking on
// writing to cache
ctx.waitUntil(cache.put(cacheKey, response.clone()));
}
return response;
},
};
Loading

1 comment on commit 893345e

@vercel
Copy link

@vercel vercel bot commented on 893345e Feb 10, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.