Skip to content

Commit

Permalink
feat: dynamic og:image generation (#85)
Browse files Browse the repository at this point in the history
  • Loading branch information
moonlitgrace authored Sep 18, 2024
1 parent 1198275 commit c1f7549
Show file tree
Hide file tree
Showing 18 changed files with 164 additions and 71 deletions.
18 changes: 9 additions & 9 deletions __tests__/lib/cn.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { cn } from "@/lib/utils"
import { cn } from '@/lib/utils';

describe('check classnames (twMerge)', () => {
it('normal checks', () => {
expect(cn("w-full", "h-full")).toBe("w-full h-full");
expect(cn("md:max-h-[1vw]", "md:hover:max-h-[10vw]")).toBe(
"md:max-h-[1vw] md:hover:max-h-[10vw]"
expect(cn('w-full', 'h-full')).toBe('w-full h-full');
expect(cn('md:max-h-[1vw]', 'md:hover:max-h-[10vw]')).toBe(
'md:max-h-[1vw] md:hover:max-h-[10vw]',
);
})
});

it('whitespace check', () => {
expect(cn(" w-full", "h-full ")).toBe(
"w-full h-full"
expect(cn(' w-full', 'h-full ')).toBe(
'w-full h-full',
);
})
})
});
});
12 changes: 6 additions & 6 deletions __tests__/lib/escape-text.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { escapeText } from "@/lib/utils"
import { escapeText } from '@/lib/utils';

describe('escape text', () => {
it('should return lower case of plainText', () => {
expect(escapeText('plainText')).toBe('plaintext');
})
});

it('should return sluggified version of text', () => {
expect(escapeText('Hello World')).toBe('hello-world');
expect(escapeText('Moonlit@grace')).toBe('moonlit-grace');
})
});

it('should handle multiplte special characters', () => {
expect(escapeText('hello-----world')).toBe('hello-world');
expect(escapeText('moon&&&lit***grace')).toBe('moon-lit-grace')
})
})
expect(escapeText('moon&&&lit***grace')).toBe('moon-lit-grace');
});
});
10 changes: 5 additions & 5 deletions __tests__/lib/extract-pagragraphs.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { extractParagraphs } from "@/lib/utils"
import { extractParagraphs } from '@/lib/utils';

describe('extract paragraphs only from markdown', () => {
it('should return paragraph', () => {
Expand All @@ -7,7 +7,7 @@ describe('extract paragraphs only from markdown', () => {
> WHOAMI
Moonlitgrace is a good bwoy!.`
expect(extractParagraphs(mockMarkdown)).toBe('Moonlitgrace is a good bwoy!.')
})
})
Moonlitgrace is a good bwoy!.`;
expect(extractParagraphs(mockMarkdown)).toBe('Moonlitgrace is a good bwoy!.');
});
});
8 changes: 4 additions & 4 deletions __tests__/lib/format-date.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { formatDate } from "@/lib/utils";
import { formatDate } from '@/lib/utils';

test('check formated date', () => {
const date = new Date("2023-03-11T02:37:40.790Z");
expect(formatDate(date)).toBe("Mar 11, 2023");
})
const date = new Date('2023-03-11T02:37:40.790Z');
expect(formatDate(date)).toBe('Mar 11, 2023');
});
14 changes: 7 additions & 7 deletions __tests__/lib/strip-html-tags.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { stripHtmlTags } from "@/lib/utils"
import { stripHtmlTags } from '@/lib/utils';

describe('stripe html tags', () => {
it('should return plaintext itself', () => {
expect(stripHtmlTags('plain-text')).toBe('plain-text')
})
expect(stripHtmlTags('plain-text')).toBe('plain-text');
});

it('should return content inside', () => {
expect(stripHtmlTags('<strong>Moonlitgrace</strong>')).toBe('Moonlitgrace')
expect(stripHtmlTags('<h1><italic>Hello World</italic></h1>')).toBe('Hello World')
})
})
expect(stripHtmlTags('<strong>Moonlitgrace</strong>')).toBe('Moonlitgrace');
expect(stripHtmlTags('<h1><italic>Hello World</italic></h1>')).toBe('Hello World');
});
});
14 changes: 7 additions & 7 deletions __tests__/lib/truncate.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { truncate } from "@/lib/utils"
import { truncate } from '@/lib/utils';

describe('truncate char', () => {
const exampleStr = 'Step into Moonlitgrace'
const exampleStr = 'Step into Moonlitgrace';

it('shouldn"t truncate char shorter than n', () => {
expect(truncate(exampleStr, 25)).toBe(exampleStr)
})
expect(truncate(exampleStr, 25)).toBe(exampleStr);
});

it('shouldn truncate char larger than n', () => {
expect(truncate(exampleStr, 20)).toBe('Step into Moonlit...')
})
})
expect(truncate(exampleStr, 20)).toBe('Step into Moonlit...');
});
});
16 changes: 8 additions & 8 deletions __tests__/lib/validate-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,27 @@
import { validateFile } from "@/lib/utils";
import { validateFile } from '@/lib/utils';

describe('check File validation', () => {
it('should return true if File is valid', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
expect(validateFile(file)).toBeTruthy();
})
});

it('should return false if File is invalid', () => {
const file = {} as File;
expect(validateFile(file)).toBeFalsy();
})
});

it('should return false if File name is invalid', () => {
const file = new File(['content'], '', { type: 'text/plain' });
expect(validateFile(file)).toBeFalsy();
})
});

it('should return false if File size is invalid', () => {
const file = new File(['content'], 'test.txt', { type: 'text/plain' });
// force set size property
Object.defineProperty(file, 'size', {
value: 0
})
value: 0,
});
expect(validateFile(file)).toBeFalsy();
})
})
});
});
28 changes: 17 additions & 11 deletions app/(routes)/(main)/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,30 +14,36 @@ export async function generateMetadata({
}: {
params: { slug: string };
}): Promise<Metadata> {
const { title, content, cover, slug } = (
await db
.select({ title: posts.title, content: posts.content, cover: posts.cover, slug: posts.slug })
.from(posts)
.where(eq(posts.slug, params.slug))
const { title, content, cover, slug, tag, createdAt } = (
await db.select().from(posts).where(eq(posts.slug, params.slug))
)[0];
const description = truncate(extractParagraphs(content), 160);

// og: dynamic image
const ogImgUrl = new URL(process.env.NEXT_PUBLIC_APP_URL + '/api/og');
ogImgUrl.searchParams.set('title', title);
ogImgUrl.searchParams.set('description', description);
ogImgUrl.searchParams.set('tag', tag);
ogImgUrl.searchParams.set('createdAt', formatDate(createdAt));
if (cover) ogImgUrl.searchParams.set('cover', cover);

return {
title,
description,
openGraph: {
title,
description,
...(cover && {
images: {
url: cover,
},
}),
images: {
url: ogImgUrl.toString(),
width: 1200,
height: 630,
alt: title,
},
url: process.env.NEXT_PUBLIC_APP_URL + '/blog/' + slug,
siteName: 'Moonlitgrace',
locale: 'en_US',
type: 'article',
}
},
};
}

Expand Down
2 changes: 1 addition & 1 deletion app/(routes)/(main)/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const metadata: Metadata = {
siteName: 'Moonlitgrace',
locale: 'en_US',
type: 'website',
}
},
};

export default async function BlogPage() {
Expand Down
2 changes: 1 addition & 1 deletion app/_components/_main/table-of-contents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const TableOfContents = ({ headings }: { headings: string[] }) => {
<PopoverContent
sideOffset={20}
side="left"
className="w-max hidden flex-col gap-4 rounded-2xl border bg-background p-5 md:flex"
className="hidden w-max flex-col gap-4 rounded-2xl border bg-background p-5 md:flex"
>
<h3 className="font-bold">Table of Contents</h3>
<div className="flex flex-col gap-1">
Expand Down
76 changes: 76 additions & 0 deletions app/api/og/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/* eslint-disable @next/next/no-img-element */
/* eslint-disable jsx-a11y/alt-text */
import { arrayBufferToBase64 } from '@/lib/utils';
import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(request: Request) {
try {
const { searchParams } = new URL(request.url);

const title = searchParams.get('title');
const description = searchParams.get('description');
const tag = searchParams.get('tag');
const createdAt = searchParams.get('createdAt');
const cover = searchParams.get('cover');

const DM_sans_Black_fontdata = await fetch(
new URL('../../../assets/fonts/dm-sans/dm-sans-900-normal.ttf', import.meta.url),
).then((res) => res.arrayBuffer());
const DM_sans_Regular_fontdata = await fetch(
new URL('../../../assets/fonts/dm-sans/dm-sans-400-normal.ttf', import.meta.url),
).then((res) => res.arrayBuffer());

const altCoverData = await fetch(new URL('../../../assets/images/icon.png', import.meta.url))
.then((res) => res.arrayBuffer())
.then((buffer) => `data:image/png;base64,${arrayBufferToBase64(buffer)}`);

return new ImageResponse(
(
<div tw="bg-[#030712] w-full h-full flex items-center p-20">
<div tw="flex flex-col flex-1 mr-10">
<div tw="flex items-center">
<span
style={{ fontFamily: 'DM_sans_Black' }}
tw="bg-[#6D28D9] text-[#F9FAFB] p-1 px-3 rounded-full capitalize"
>
{tag}
</span>
<span style={{ fontFamily: 'DM_sans_Black' }} tw="ml-3 text-[#F9FAFB]/75 uppercase">
{createdAt}
</span>
</div>
<h1 style={{ fontFamily: 'DM_sans_Black' }} tw="text-[#F9FAFB] text-6xl">
{title}
</h1>
<p style={{ fontFamily: 'DM_sans_Regular' }} tw="text-[#F9FAFB]/75 text-2xl">
{description}
</p>{' '}
</div>
<img
tw="w-[300px] h-[300px] rounded-3xl"
style={{ objectFit: 'cover' }}
src={cover ?? altCoverData}
/>
</div>
),
{
fonts: [
{
name: 'DM_sans_Black',
data: DM_sans_Black_fontdata,
style: 'normal',
},
{
name: 'DM_sans_Regular',
data: DM_sans_Regular_fontdata,
style: 'normal',
},
],
},
);
} catch (err) {
return new Response('Failed to generate OG image' + err, { status: 500 });
}
}
Binary file added assets/fonts/dm-sans/dm-sans-400-normal.ttf
Binary file not shown.
Binary file added assets/fonts/dm-sans/dm-sans-700-normal.ttf
Binary file not shown.
Binary file added assets/fonts/dm-sans/dm-sans-900-normal.ttf
Binary file not shown.
Binary file added assets/images/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 6 additions & 6 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import type { Config } from 'jest'
import nextJest from 'next/jest.js'
import type { Config } from 'jest';
import nextJest from 'next/jest.js';

const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files in your test environment
dir: './',
})
});

// Add any custom config to be passed to Jest
const config: Config = {
Expand All @@ -15,8 +15,8 @@ const config: Config = {
// Path alias
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/$1',
}
}
},
};

// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
export default createJestConfig(config)
export default createJestConfig(config);
13 changes: 12 additions & 1 deletion lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,5 +39,16 @@ export function extractParagraphs(markdown: string) {
}

export function truncate(str: string, n: number) {
return (str.length > n) ? str.slice(0, n - 3) + '...' : str;
return str.length > n ? str.slice(0, n - 3) + '...' : str;
}

// TODO: add test for this function
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
10 changes: 5 additions & 5 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ const nextConfig = {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js'
}
}
}
}
as: '*.js',
},
},
},
},
};

export default withSentryConfig(nextConfig, {
Expand Down

0 comments on commit c1f7549

Please sign in to comment.