From d533d6220d90685df0643250e3025ebfc6e6b88e Mon Sep 17 00:00:00 2001 From: Hippari Date: Wed, 13 Nov 2024 22:49:57 +0100 Subject: [PATCH] feat(social): Add generic OpenGraph image --- src/app/opengraph-image.tsx | 78 +++++++++++++++++++++++++++ src/core/utils/social.ts | 34 ++++++++++++ src/core/utils/validation/api.spec.ts | 77 ++++++++++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/app/opengraph-image.tsx create mode 100644 src/core/utils/social.ts create mode 100644 src/core/utils/validation/api.spec.ts diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx new file mode 100644 index 0000000..2854372 --- /dev/null +++ b/src/app/opengraph-image.tsx @@ -0,0 +1,78 @@ +import { loadGoogleFont } from '@/core/utils/social'; +import { viewsService } from '@/services/views.service'; +import { ImageResponse } from 'next/og'; + +export const runtime = 'edge'; + +export const alt = 'Kanojo'; + +export const contentType = 'image/png'; + +/** + * Generates an Open Graph image response for the Kanojo application. + * + * This function creates an image response with a styled div containing + * a title and a description. The image is intended to be used as an + * Open Graph image for social media sharing. + * @returns An ImageResponse object. + */ +export default async function Image() { + const counts = await viewsService.getCurrentCounts(); + + return new ImageResponse( + ( +
+
+

+ Kanojo +

+

+ A community-run database +

+

for gravure idols.

+
+
+
+

Movies

+

+ {counts?.movie_count} +

+
+
+

People

+

+ {counts?.person_count} +

+
+
+

Studios

+

+ {counts?.studio_count} +

+
+
+
+ ), + { + fonts: [ + { + data: await loadGoogleFont('Noto+Sans+JP', '700'), + name: 'Noto Sans JP', + style: 'normal', + }, + { + data: await loadGoogleFont('Noto+Sans+JP', '800'), + name: 'Noto Sans JP', + style: 'normal', + }, + { + data: await loadGoogleFont('Noto+Sans+JP', '900'), + name: 'Noto Sans JP', + style: 'normal', + }, + ], + height: 630, + width: 1200, + }, + ); +} diff --git a/src/core/utils/social.ts b/src/core/utils/social.ts new file mode 100644 index 0000000..435881e --- /dev/null +++ b/src/core/utils/social.ts @@ -0,0 +1,34 @@ +/** + * Asynchronously loads a Google Font and returns its data as an ArrayBuffer. + * @param font - The name of the Google Font to load. + * @param weight - The weight of the font to load. + * @returns A promise that resolves to an ArrayBuffer containing the font data. + * @throws Will throw an error if the font data could not be loaded. + */ +export async function loadGoogleFont( + font: string, + weight: + | "100" + | "200" + | "300" + | "400" + | "500" + | "600" + | "700" + | "800" + | "900" = "400", +): Promise { + const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}`; + const cssFile = await fetch(url); + const css = await cssFile.text(); + const resource = /src: url\((.+)\) format\('(opentype|truetype)'\)/.exec(css); + + if (resource) { + const response = await fetch(resource[1]); + if (response.status == 200) { + return await response.arrayBuffer(); + } + } + + throw new Error("failed to load font data"); +} diff --git a/src/core/utils/validation/api.spec.ts b/src/core/utils/validation/api.spec.ts new file mode 100644 index 0000000..e946192 --- /dev/null +++ b/src/core/utils/validation/api.spec.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { + apiMovieSearchResultSchema, + apiMovieSearchResultSchemaArray, +} from "./api"; + +describe("API schema - movie search result", () => { + it("should validate a correct movie search result", () => { + const validData = { + id: 1, + original_title: "Inception", + release_date: "2010-07-16", + title: "Inception", + }; + + expect(() => apiMovieSearchResultSchema.parse(validData)).not.toThrow(); + }); + + it("should invalidate an incorrect movie search result", () => { + const invalidData = { + id: "1", // id should be a number + original_title: "Inception", + }; + + expect(() => apiMovieSearchResultSchema.parse(invalidData)).toThrow(); + }); + + it("should validate a movie search result with optional fields", () => { + const validData = { + id: 1, + original_title: "Inception", + }; + + expect(() => apiMovieSearchResultSchema.parse(validData)).not.toThrow(); + }); +}); + +describe("API schema - movie search result array", () => { + it("should validate an array of correct movie search results", () => { + const validDataArray = [ + { + id: 1, + original_title: "Inception", + release_date: "2010-07-16", + title: "Inception", + }, + { + id: 2, + original_title: "The Matrix", + release_date: "1999-03-31", + title: "The Matrix", + }, + ]; + + expect(() => apiMovieSearchResultSchemaArray.parse(validDataArray)).not + .toThrow(); + }); + + it("should invalidate an array with an incorrect movie search result", () => { + const invalidDataArray = [ + { + id: 1, + original_title: "Inception", + release_date: "2010-07-16", + title: "Inception", + }, + { + id: "2", // id should be a number + original_title: "The Matrix", + }, + ]; + + expect(() => apiMovieSearchResultSchemaArray.parse(invalidDataArray)) + .toThrow(); + }); +});