| import type { CollectionEntry } from "astro:content";
|
| import { getCollection } from "astro:content";
|
| import * as fs from "node:fs";
|
| import type { APIContext, GetStaticPaths } from "astro";
|
| import satori from "satori";
|
| import sharp from "sharp";
|
| import { removeFileExtension } from "@/utils/url-utils";
|
|
|
| import { profileConfig } from "../../config/profileConfig";
|
| import { siteConfig } from "../../config/siteConfig";
|
|
|
| type Weight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900;
|
|
|
| type FontStyle = "normal" | "italic";
|
| interface FontOptions {
|
| data: Buffer | ArrayBuffer;
|
| name: string;
|
| weight?: Weight;
|
| style?: FontStyle;
|
| lang?: string;
|
| }
|
| export const prerender = true;
|
|
|
| export const getStaticPaths: GetStaticPaths = async () => {
|
| if (!siteConfig.generateOgImages) {
|
| return [];
|
| }
|
|
|
| const allPosts = await getCollection("posts");
|
| const publishedPosts = allPosts.filter((post) => !post.data.draft);
|
|
|
| return publishedPosts.map((post) => {
|
|
|
| const slug = removeFileExtension(post.id);
|
| return {
|
| params: { slug },
|
| props: { post },
|
| };
|
| });
|
| };
|
|
|
| let fontCache: { regular: Buffer | null; bold: Buffer | null } | null = null;
|
|
|
| async function fetchNotoSansSCFonts() {
|
| if (fontCache) return fontCache;
|
| try {
|
| const cssResp = await fetch(
|
| "https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;700&display=swap",
|
| );
|
| if (!cssResp.ok) throw new Error("Failed to fetch Google Fonts CSS");
|
| const cssText = await cssResp.text();
|
|
|
| const getUrlForWeight = (weight: number) => {
|
| const blockRe = new RegExp(
|
| `@font-face\\s*{[^}]*font-weight:\\s*${weight}[^}]*}`,
|
| "g",
|
| );
|
| const match = cssText.match(blockRe);
|
| if (!match || match.length === 0) return null;
|
| const urlMatch = match[0].match(/url\((https:[^)]+)\)/);
|
| return urlMatch ? urlMatch[1] : null;
|
| };
|
|
|
| const regularUrl = getUrlForWeight(400);
|
| const boldUrl = getUrlForWeight(700);
|
|
|
| if (!regularUrl || !boldUrl) {
|
| console.warn(
|
| "Could not find font urls in Google Fonts CSS; falling back to no fonts.",
|
| );
|
| fontCache = { regular: null, bold: null };
|
| return { regular: null, bold: null };
|
| }
|
|
|
| const [rResp, bResp] = await Promise.all([
|
| fetch(regularUrl),
|
| fetch(boldUrl),
|
| ]);
|
| if (!rResp.ok || !bResp.ok) {
|
| console.warn(
|
| "Failed to download font files from Google; falling back to no fonts.",
|
| );
|
| fontCache = { regular: null, bold: null };
|
| return { regular: null, bold: null };
|
| }
|
|
|
| const rBuf = Buffer.from(await rResp.arrayBuffer());
|
| const bBuf = Buffer.from(await bResp.arrayBuffer());
|
| fontCache = { regular: rBuf, bold: bBuf };
|
| return fontCache;
|
| } catch (err) {
|
| console.warn("Error fetching fonts:", err);
|
| fontCache = { regular: null, bold: null };
|
| return { regular: null, bold: null };
|
| }
|
| }
|
|
|
| export async function GET({
|
| props,
|
| }: APIContext<{ post: CollectionEntry<"posts"> }>) {
|
| const { post } = props;
|
|
|
|
|
| const { regular: fontRegular, bold: fontBold } = await fetchNotoSansSCFonts();
|
|
|
|
|
| let avatarBase64: string;
|
|
|
|
|
| if (profileConfig.avatar?.startsWith("http")) {
|
|
|
| avatarBase64 = profileConfig.avatar;
|
| } else {
|
|
|
| const avatarPath = profileConfig.avatar?.startsWith("/")
|
| ? `./public${profileConfig.avatar}`
|
| : `./src/${profileConfig.avatar}`;
|
| const avatarBuffer = fs.readFileSync(avatarPath);
|
| avatarBase64 = `data:image/png;base64,${avatarBuffer.toString("base64")}`;
|
| }
|
|
|
| let iconPath = "./public/favicon/favicon-dark-192.png";
|
| if (siteConfig.favicon.length > 0) {
|
| iconPath = `./public${siteConfig.favicon[0].src}`;
|
| }
|
| const iconBuffer = fs.readFileSync(iconPath);
|
| const iconBase64 = `data:image/png;base64,${iconBuffer.toString("base64")}`;
|
|
|
| const hue = siteConfig.themeColor.hue;
|
| const primaryColor = `hsl(${hue}, 90%, 65%)`;
|
| const textColor = "hsl(0, 0%, 95%)";
|
|
|
| const subtleTextColor = `hsl(${hue}, 10%, 75%)`;
|
| const backgroundColor = `hsl(${hue}, 15%, 12%)`;
|
|
|
| const pubDate = post.data.published.toLocaleDateString("en-US", {
|
| year: "numeric",
|
| month: "short",
|
| day: "numeric",
|
| });
|
|
|
| const description = post.data.description;
|
|
|
| const template = {
|
| type: "div",
|
| props: {
|
| style: {
|
| height: "100%",
|
| width: "100%",
|
| display: "flex",
|
| flexDirection: "column",
|
| backgroundColor: backgroundColor,
|
| fontFamily:
|
| '"Noto Sans SC", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
| padding: "60px",
|
| },
|
| children: [
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| width: "100%",
|
| display: "flex",
|
| alignItems: "center",
|
| gap: "20px",
|
| },
|
| children: [
|
| {
|
| type: "img",
|
| props: {
|
| src: iconBase64,
|
| width: 48,
|
| height: 48,
|
| style: { borderRadius: "10px" },
|
| },
|
| },
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| fontSize: "36px",
|
| fontWeight: 600,
|
| color: subtleTextColor,
|
| },
|
| children: siteConfig.title,
|
| },
|
| },
|
| ],
|
| },
|
| },
|
|
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| display: "flex",
|
| flexDirection: "column",
|
| justifyContent: "center",
|
| flexGrow: 1,
|
| gap: "20px",
|
| },
|
| children: [
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| display: "flex",
|
| alignItems: "flex-start",
|
| },
|
| children: [
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| width: "10px",
|
| height: "68px",
|
| backgroundColor: primaryColor,
|
| borderRadius: "6px",
|
| marginTop: "14px",
|
| },
|
| },
|
| },
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| fontSize: "72px",
|
| fontWeight: 700,
|
| lineHeight: 1.2,
|
| color: textColor,
|
| marginLeft: "25px",
|
| display: "-webkit-box",
|
| overflow: "hidden",
|
| textOverflow: "ellipsis",
|
| lineClamp: 3,
|
| WebkitLineClamp: 3,
|
| WebkitBoxOrient: "vertical",
|
| },
|
| children: post.data.title,
|
| },
|
| },
|
| ],
|
| },
|
| },
|
| description && {
|
| type: "div",
|
| props: {
|
| style: {
|
| fontSize: "32px",
|
| lineHeight: 1.5,
|
| color: subtleTextColor,
|
| paddingLeft: "35px",
|
| display: "-webkit-box",
|
| overflow: "hidden",
|
| textOverflow: "ellipsis",
|
| lineClamp: 2,
|
| WebkitLineClamp: 2,
|
| WebkitBoxOrient: "vertical",
|
| },
|
| children: description,
|
| },
|
| },
|
| ],
|
| },
|
| },
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| display: "flex",
|
| justifyContent: "space-between",
|
| alignItems: "center",
|
| width: "100%",
|
| },
|
| children: [
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| display: "flex",
|
| alignItems: "center",
|
| gap: "20px",
|
| },
|
| children: [
|
| {
|
| type: "img",
|
| props: {
|
| src: avatarBase64,
|
| width: 60,
|
| height: 60,
|
| style: { borderRadius: "50%" },
|
| },
|
| },
|
| {
|
| type: "div",
|
| props: {
|
| style: {
|
| fontSize: "28px",
|
| fontWeight: 600,
|
| color: textColor,
|
| },
|
| children: profileConfig.name,
|
| },
|
| },
|
| ],
|
| },
|
| },
|
| {
|
| type: "div",
|
| props: {
|
| style: { fontSize: "28px", color: subtleTextColor },
|
| children: pubDate,
|
| },
|
| },
|
| ],
|
| },
|
| },
|
| ],
|
| },
|
| };
|
|
|
| const fonts: FontOptions[] = [];
|
| if (fontRegular) {
|
| fonts.push({
|
| name: "Noto Sans SC",
|
| data: fontRegular,
|
| weight: 400,
|
| style: "normal",
|
| });
|
| }
|
| if (fontBold) {
|
| fonts.push({
|
| name: "Noto Sans SC",
|
| data: fontBold,
|
| weight: 700,
|
| style: "normal",
|
| });
|
| }
|
|
|
| const svg = await satori(template, {
|
| width: 1200,
|
| height: 630,
|
| fonts,
|
| });
|
|
|
| const png = await sharp(Buffer.from(svg)).png().toBuffer();
|
|
|
| return new Response(new Uint8Array(png), {
|
| headers: {
|
| "Content-Type": "image/png",
|
| "Cache-Control": "public, max-age=31536000, immutable",
|
| },
|
| });
|
| }
|
|
|