Gerando imagens de gráfico aberto com Astro e Satori • lowmess
Há vários séculos atrás, quando eu me preocupava com essas coisas, queria atualizar todos os cabeçalhos das minhas diversas contas de mídia social para usar o mesmo design. Como cada plataforma queria tamanhos ligeiramente diferentes, isso era muito chato de fazer manualmente em software de design. Felizmente eu já conhecia uma ferramenta que se adaptava aos diversos tamanhos que cada plataforma exigia: HTML & CSS. Então, criei uma ferramenta para abusar da então nova biblioteca Puppeteer para fazer capturas de tela de uma determinada página HTML nos tamanhos necessários. E funcionou muito bem!
Estou longe de ser a única pessoa a pensar que a flexibilidade destas tecnologias as torna perfeitas para imagens dinâmicas. Quando Vercel se deparou com problemas semelhantes ao gerar imagens Open Graph em escala, eles criaram uma biblioteca inteira sobre isso. Quando eu estava pensando em fazer o mesmo neste site, combinar o recurso de endpoints do Astro com aquela biblioteca parecia uma opção ergonômica. Acabou sendo ainda melhor do que eu pensava. (E, antes de irmos longe demais, algumas pessoas ainda seguem o caminho do Titereiro!)
Em primeiro lugar, o que é uma imagem de gráfico aberto?
Sabe quando você cola um link em algum aplicativo e parece inserir magicamente uma descrição e uma imagem para aquela página? Tudo isso é alimentado por algo chamado protocolo Open Graph. Originalmente desenvolvido pela Meta para aprimorar links compartilhados para (e do) Facebook, desde então se tornou o padrão de fato para exibir informações em visualizações de links.
E estamos gerando essas imagens como?
Em primeiro lugar, presumo que você já tenha um site Astro configurado. Em segundo lugar, precisamos instalar algumas dependências para que isso funcione:
npm i satori react sharp
Satori renderiza JSX para SVG. React é necessário para o mecanismo de layout do Satori. Sharp é usado para pegar o SVG resultante e transformá-lo em PNG. Você também pode usar resvg-js para esta última parte, mas eu já tinha o Sharp instalado, então é isso que meus exemplos usarão. Se você usa os componentes de imagem do Astro, provavelmente já tem o Sharp instalado também. Como este é o Astro, a menos que você decida explicitamente criar um componente React que use Satori e Sharp no cliente por algum motivo, nenhuma dessas dependências será enviada ao cliente.
Fazendo um ajudante
Neste site tenho dois templates de imagens que quero mostrar. Para postagens de blog, quero incluir o título e a data da postagem. Para todas as outras páginas quero incluir uma imagem genérica que tenha a mesma vibração geral, mas com o slogan do site. Sendo esse o caso, faz sentido separar a renderização do modelo das definições do modelo.
import { type ReactNode } from "react";
import satori from "satori";
import sharp from "sharp";
export async function generateOgImage(template: ReactNode) {
const svg = await satori(template, {
width: 1200,
height: 630,
fonts: (),
});
return await sharp(Buffer.from(svg)).png().toBuffer();
}
Algumas perguntas podem surgir aqui.
Por que 1200×630?
Muito boa pergunta. Não sei. Mas é a recomendação mais comum.
Por que devolver um Buffer?
Como iremos retorná-los de um terminal, precisamos codificar as informações de uma forma que caiba em um Response.
Por que é fonts Vazio?
Na verdade, precisamos adicionar algumas coisas lá, mas isso depende se você está usando fontes locais ou de terceiros (ou, suponho, alguma combinação das duas). Se você estiver carregando suas fontes do Google ou de outra fonte de terceiros, você deve fetch o arquivo e transformá-lo em um ArrayBuffer.
const myFetchedFont = await fetch("https://example.com/font-file.woff");
const myFetchedFontData = await myFetchedFont.arrayBuffer();
export async function generateOgImage(template: ReactNode) {
const svg = await satori(template, {
width: 1200,
height: 630,
fonts: (
{
name: "MyFetchedFont",
data: myFetchedFontData,
},
),
});
return await sharp(Buffer.from(svg)).png().toBuffer();
}
Para fontes locais, fs.readFileSync é seu amigo. Observe que o caminho para o arquivo de fonte deve ser da raiz do projeto, e não relativo ao auxiliar.
import fs from "fs";
const myLocalFont = fs.readFileSync("./src/fonts/my-local-font.woff");
export async function generateOgImage(template: ReactNode) {
const svg = await satori(template, {
width: 1200,
height: 630,
fonts: (
{
name: "MyLocalFont",
data: myLocalFont,
},
),
});
return await sharp(Buffer.from(svg)).png().toBuffer();
}
Corte de biscoitos
Com o auxiliar instalado, precisamos de alguns modelos para renderizar. Como mencionei, tenho dois, mas eles compartilham um layout, então na verdade tenho três componentes. Isso irá variar de site para site, mas a ideia geral é que estamos criando funções que retornam JSX para o Satori renderizar.
export function BlogPostOgImage({ post }: { post: BlogPost }) {
return (
<div
style={{
display: "flex",
width: "100%",
height: "100%",
}}
>
My post is called {post.title}
div>
);
}
Satori suporta apenas um subconjunto de CSS e mesmo assim apenas através de objetos de estilo inline. Não é a melhor experiência do mundo, mas também é importante lembrar que essas imagens podem ser renderizadas bem pequenas. Se for complicado editar o modelo ou se você estiver tendo problemas com seu design, geralmente é um sinal para simplificar.
Ok, mas como coloco isso no meu site?
Astro é mais comumente usado para gerar páginas HTML e seus CSS e JavaScript associados. Mas você pode usar endpoints para retornar qualquer tipo de dados que desejarmos. Nesse caso, queremos criar uma imagem para cada postagem do blog. Vamos criar uma nova rota em pages/blog/(slug)/og-image.png.ts para servir essas imagens.
import { type APIContext } from "astro";
import { generateOgImage, OgBlogPostImage } from "#utils/og-image.tsx";
const allPosts = await getAllPosts();
// Generate a path & return a `GET` endpoint for each post
export async function getStaticPaths() {
return allPosts.map((post) => ({
params: { slug: post.slug },
}));
}
export async function GET({ params }: APIContext) {
const { slug } = params;
const post = allPosts.find((post) => post.slug === slug);
// If we don't have a post, we don't have an image
if (!post) {
return new Response(null, {
status: 404,
statusText: "Not found",
});
}
// React isn't in context, so call the template as a normal function
const png = await generateOgImage(OgBlogPostImage({ post }));
// Cast from `Buffer` to `Uint8Array`
return new Response(png as Uint8Array<ArrayBuffer>, {
headers: { "Content-Type": "image/png" },
});
}
Claro, isso vai mudar dependendo de como você coloca suas postagens em seu site – eu uso uma coleção de conteúdo – mas a ideia central é a mesma. Busque suas postagens e crie uma estática GET ponto final para cada um. E se você estiver no modo SSR, basta remover o getStaticPaths e gere a imagem instantaneamente! Você provavelmente desejaria adicionar algum cache nesse caso.
Está tudo em seu
Once the the images are being generated, you have tell other sites where to find them. If you clicked on the Open Graph protocol link above you probably spotted the secret sauce. Even if you didn’t I’ll still tell you: we need to add a tag to the of our pages. This probably means adding a prop to your layout component (with a default value if you have a generic image).
---
type Props = {
ogImageUrl?: string;
};
const { ogImageUrl = `${Astro.url.origin}/og-image.png` } = Astro.props;
---
<!doctype html>
<html>
<head>
<meta property="og:image" content={ogImageUrl} />
head>
<body>body>
html>
Em seguida, atualizamos nossa página de postagem do blog:
---
import Layout from "#layouts/global.astro";
---
<Layout ogImageUrl={`${Astro.url}/og-image`}>
Layout>
Observe que os links para as imagens são links absolutos derivados de Astro.url. Alguns sites serão capazes de captar links relativos, mas em geral é sempre mais seguro ter links absolutos.
E então o que?
Em seguida, você publica o link para a postagem do blog que escreveu sobre como colocar imagens Open Graph em seu site! Esse é o meu plano, pelo menos.
