Astro 自动生成 Open Graph & Twitter card 图片😄

本篇文章教你在 Astro 为你的文章自动生成 Open Graph & Twitter card 图片。

前言

什么是 Open Graph & Twitter card

Open Graph 是一种用来在社交媒体上分享链接时,自动生成预览图的协议,支持的平台有 Facebook、LinkedIn 等。Twitter card 是 Twitter 自己的协议,支持的平台只有 Twitter。

两者都是基于 HTML 的 <meta> 标签实现的。

例如,下面是一个 Open Graph 的例子:

<meta property="og:title" content="Page title" />
<meta property="og:description" content="This is description" />
<meta property="og:url" content="http://www.example.com/post/1" />
<meta property="og:image" content="http://example.com/post1.jpg" />

Twitter card 的例子:

<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Page title" />
<meta name="twitter:description" content="This is description" />
<meta name="twitter:image" content="http://example.com/post1.jpg" />

各平台会根据这些标签的内容,自动把你发布的链接转换成预览图,如下图所示:

Twitter card image

为什么要自动生成

对于我们的网站来说,我们可以在整个网站的 root layout 加上整站的 Open Graph 和 Twitter card 的标签。而对于每篇文章,我们可以手动给每篇文章选择配图。

而大部份情况下,我不想给每篇文章都选择配图,这时候我们就可以生成固定排版格式的配图。

所用技术

  • Satori:Satori 是 Vercel 开源的一个工具,可以用来把HTML、CSS 转换成 SVG。
  • resvg-js:resvg-js 是一个 Rust 实现的 SVG 渲染引擎,可以用来把 SVG 转换成 PNG。
  • Astro:本篇文章是在基于 Astro 的项目中实现的,但核心代码与 Astro 无关,你也可以在其他项目中使用。

整体流程与思路

  • 使用 Endpoint 在构建 SSG 时调用生成图片函数。
  • 使用 Satori 来按照固定模板生成图片。
  • Markdown 文章使用 frontmatter 指定 ogImage。
  • 未指定 ogImage 的文章使用自动生成的图片。
  • ogImage 通过 Props 传给 layout
  • layout 生成需要的 HTML meta 结构

实现

API Endpoints

对于所有的文章,如何触发生成图片逻辑,我们这里使用 Astro 的 Endpoints。当前其他框架也有类似功能,比如 Next.js 和 Nuxt.js。

src/posts文件夹下,建一个 [slug] 文件夹,里面建一个 index.png.ts 文件,文件内容如下:

src/posts/[slug]/index.png.ts
import type { APIRoute } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { generateOgImageForPost } from "@utils/generateOgImages";
export async function getStaticPaths() {
const posts = await getCollection("blog").then(p =>
p.filter(({ data }) => !data.draft && !data.ogImage)
);
return posts.map(post => ({
params: { slug: post.slug },
props: post,
}));
}
export const GET: APIRoute = async ({ props }) =>
new Response(await generateOgImageForPost(props as CollectionEntry<"blog">), {
headers: { "Content-Type": "image/png" },
});

解释一下上面的代码,因为我们是 SSG 静态生成,所以需要导出一个 getStaticPaths 函数来获取所有的文章。

然后导出 GET 函数,函数中调用了 generateOgImageForPost 函数,也就是接下来生成图片的核心逻辑,返回 Content-Typeimage/png 的 Response。

这样在打包的时候,就会为所有的文章执行 GET,生成图片。

生成图片

utils下新建一个 generateOgImages.tsx 文件。

generateOgImages.tsx
import satori, { type SatoriOptions } from "satori";
import { Resvg } from "@resvg/resvg-js";
import postOgImage from "./og-templates/post";
const options: SatoriOptions = {
width: 1200,
height: 630,
};
export async function generateOgImageForPost(post: CollectionEntry<"blog">) {
const svg = await satori(postOgImage(post), options);
return svgBufferToPngBuffer(svg);
}
function svgBufferToPngBuffer(svg: string) {
const resvg = new Resvg(svg);
const pngData = resvg.render();
return pngData.asPng();
}

本文件导出一个 generateOgImageForPost 函数,供 Endpoint 调用。

我们使用了 satori 库来生成 svg, 然后使用 resvg 来把 svg 转成 png 格式。

tsx 模板

satori 接收两个参数,第一个是模板,类型是 ReactNode,第二个是配置项。 在 utils/og-templates 文件夹下新建 post.tsx

utils/og-templates/post.tsx
import { SITE } from "@config";
import type { CollectionEntry } from "astro:content";
export default (post: CollectionEntry<"blog">) => {
return (
<div>
具体布局样式
</div>
);
};

因为模板是 tsx 文件被导入到 generateOgImages 使用,这也就是为啥 generateOgImages.tsx 要用 tsx 后缀名。

中文字体

其实到这里就可以基本使用了,但是我们想找一个好的中文字体。

于是我在 google fonts 找到了一个叫做 ZCOOLKuaiLe 的字体,把它下载到了 public/fonts 文件夹下。

修改我们的 generateOgImages.tsx:

generateOgImages.tsx
const isDev = import.meta.env.DEV;
const website = isDev ? "http://localhost:4321/" : SITE.website;
const fetchFonts = async () => {
const fontFileRegular = await fetch(
`${website}fonts/ZCOOL_KuaiLe/ZCOOLKuaiLe-Regular.ttf`
);
const fontRegular: ArrayBuffer = await fontFileRegular.arrayBuffer();
return { fontRegular };
};
const { fontRegular } = await fetchFonts();
const options: SatoriOptions = {
width: 1200,
height: 630,
embedFont: true,
fonts: [
{
name: "ZCOOL KuaiLe",
data: fontRegular,
weight: 400,
style: "normal",
},
],
};

通过 fetch 获取字体,然后配置 embedFont: truefonts 数组。

以上就增加了自定义字体的支持,友情提示,选字体的时候一定要看好对中文的支持程度,有的支持不好的会变成 □ 。

支持 emoji

接下来我们想实现可以支持 emoji 的功能,让我们生成的图片更酷炫一点。

再次修改 generateOgImages.tsx:

generateOgImages.tsx
import { getIconCode, loadEmoji } from "./twemoji";
const options: SatoriOptions = {
width: 1200,
height: 630,
embedFont: true,
fonts: [
{
name: "ZCOOL KuaiLe",
data: fontRegular,
weight: 400,
style: "normal",
},
],
loadAdditionalAsset: async (code: string, segment: string) => {
if (code === "emoji") {
// 处理 emoji 的情况,比如 😄
return (
`data:image/svg+xml;base64,` +
btoa(await loadEmoji("twemoji", getIconCode(segment)))
);
}
// 这里我没做处理直接返回了一个固定表情
// 应该是 ` &#xf089;` 这种,感兴趣的同学自行处理
// 参考 https://github.com/vercel/satori/tree/main/playground
return (
`data:image/svg+xml;base64,` +
btoa(await loadEmoji("twemoji", "1f92f"))
);
},
};

以上使用了 loadAdditionalAsset 配置项来处理特殊字符。

我们又引入了两个函数 getIconCodeloadEmoji,那么我们新建 twemoji.ts 文件:

utils/twemoji.ts
/**
* Modified version of https://unpkg.com/[email protected]/dist/twemoji.esm.js.
*/
/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */
const U200D = String.fromCharCode(8205);
const UFE0Fg = /\uFE0F/g;
export function getIconCode(char: string) {
return toCodePoint(char.indexOf(U200D) < 0 ? char.replace(UFE0Fg, "") : char);
}
function toCodePoint(unicodeSurrogates: string) {
const r = [];
let c = 0,
p = 0,
i = 0;
while (i < unicodeSurrogates.length) {
c = unicodeSurrogates.charCodeAt(i++);
if (p) {
r.push((65536 + ((p - 55296) << 10) + (c - 56320)).toString(16));
p = 0;
} else if (55296 <= c && c <= 56319) {
p = c;
} else {
r.push(c.toString(16));
}
}
return r.join("-");
}
export const apis = {
twemoji: (code: string) =>
"https://cdnjs.cloudflare.com/ajax/libs/twemoji/14.0.2/svg/" +
code.toLowerCase() +
".svg",
openmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/",
blobmoji: "https://cdn.jsdelivr.net/npm/@svgmoji/[email protected]/svg/",
noto: "https://cdn.jsdelivr.net/gh/svgmoji/svgmoji/packages/svgmoji__noto/svg/",
fluent: (code: string) =>
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" +
code.toLowerCase() +
"_color.svg",
fluentFlat: (code: string) =>
"https://cdn.jsdelivr.net/gh/shuding/fluentui-emoji-unicode/assets/" +
code.toLowerCase() +
"_flat.svg",
};
const emojiCache: Record<string, Promise<any>> = {};
export function loadEmoji(type: keyof typeof apis, code: string) {
const key = type + ":" + code;
if (key in emojiCache) return emojiCache[key];
if (!type || !apis[type]) {
type = "twemoji";
}
const api = apis[type];
if (typeof api === "function") {
return (emojiCache[key] = fetch(api(code)).then(r => r.text()));
}
return (emojiCache[key] = fetch(`${api}${code.toUpperCase()}.svg`).then(r =>
r.text()
));
}

这大段代码是 vercel 根据 twemoji 改的,支持了多种类型的 emoji。我直接抄了过来。

调用方法就是:

await loadEmoji("twemoji", getIconCode(segment))

这样我们就支持了在生成的图片中显示 emoji 了。

小结

到这里,我们其实就完成了生成图片的核心逻辑。

执行 npm run build

打包log

可以看到打包时执行了我们写的 Endpoint,为每篇文章生成了图片。

查看 dist/posts 文件夹

dist 中的图片

访问:http://localhost:4321/posts/astro-auto-gen-og-image.png

最终效果图

配置 OG meta

接下来,我们说一下 layout 和 markdown 文章 frontmatter,用来生成 OG 所需的 HTML meta 标签。

本部分是使用 Astro 框架,如果你使用其他框架也没关系,核心代码与框架无关,你可以跳过此部分,在其他项目按需配置。

layout 配置

首先我们在 Layout.astro 中,配置如下:

Layout.astro
---
import { SITE, OG } from "@config";
export interface Props {
title?: string;
author?: string;
description?: string;
ogImage?: string;
canonicalURL?: string;
}
// 这里从 props 接收参数,其中就有 ogImage,我们给了个默认值,是从配置文件中导入的
const {
title = SITE.title,
author = SITE.author,
description = SITE.desc,
ogImage = OG.ogImage,
canonicalURL = new URL(Astro.url.pathname, Astro.site).href,
} = Astro.props;
// 这里把 ogImage 转化一下
const socialImageURL = new URL(ogImage, Astro.url.origin).href;
---
<!doctype html>
<html lang="en">
<head>
<!-- 其他配置忽略 -->
<!-- Open Graph / Facebook -->
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={socialImageURL} />
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={socialImageURL} />
</head>
<body>
<slot />
</body>
</html>

PostDetails 配置

然后是 PostDetails.astro

PostDetails.astro
---
import Layout from "@layouts/Layout.astro";
export interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
const { title, author, description, ogImage, canonicalURL, pubDatetime, tags } =
post.data;
const { Content, headings } = await post.render();
const ogImageUrl = typeof ogImage === "string" ? ogImage : ogImage?.src;
const ogUrl = new URL(
ogImageUrl ?? `/posts/${post.slug}.png`,
Astro.url.origin
).href;
---
<Layout
title={title}
author={author}
description={description}
ogImage={ogUrl}
canonicalURL={canonicalURL}
>
your post content
</Layout>

PostDetail 是文章详情页,从文章的 frontmatter 中拿到相应数据,如果文章有自己配置的 ogImage 就用自己的,如果没有,就用文章slug 拼接将要自动生成的 url:

const ogUrl = new URL(
ogImageUrl ?? `/posts/${post.slug}.png`,
Astro.url.origin
).href;

最后传给 Layout。

文章配置

文章的 frontmatter 是由 Astro 的 Content Collections 管理的:

content/config.ts
import { SITE } from "@config";
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
canonicalURL: z.string().optional(),
// 定义 ogImage 的类型,可以是本地图片,可以是完整的网络图片字符串,可选
ogImage: image()
.refine(img => img.width >= 1200 && img.height >= 630, {
message: "OpenGraph image must be at least 1200 X 630 pixels!",
})
.or(z.string())
.optional(),
}),
});
export const collections = { blog };

markdown 文章示例

content/blog/example-post.md
---
title: "Astro 自动生成 Open Graph & twitter card 图片"
description: "本教程将带你手把手用 Rust 实现一个命令行的 TODO List。"
ogImage: "https://example.png"
---
## markdown 文章示例
上面 ogImage 如果不写,则会使用自动生成的图片。

最终效果

在 Twitter 上编辑推文,内容是我们的文章链接,然后发布,效果如下:

在 Twitter 上展示的效果

总结

至此我们完成了我们想要的全部功能,全部代码在我的博客仓库。Next.js 其实有自己的生成图片功能,也是使用的 Satori,感兴趣的朋友可以把这套移植到其他系统。