Diário do Projeto
rafazingano.com.br

Content collections, busca sem servidor e capas geradas no build

Publicar um post virou criar um arquivo. A busca roda inteira no navegador, sem backend e sem serviço pago. E cada artigo ganha um card social gerado automaticamente, sem eu abrir editor de imagem.

Listagem do blog do site, com o card em destaque ocupando duas colunas e os demais artigos em grade.

Performance Baseline

Páginas indexadas na busca
11
JavaScript de framework
0 KB

Com a estrutura em pé, faltava o que o site existe para servir: conteúdo. Três peças precisavam funcionar sem servidor — publicação, busca e prévia social.

Conteúdo como arquivo, validado no build

As content collections do Astro resolvem publicação com Markdown, mas o que importa mesmo é o schema. Cada coleção declara a forma do frontmatter, e o build falha se algum campo estiver errado.

src/content.config.ts
import { defineCollection, reference } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'zod';

const blog = defineCollection({
  loader: glob({ base: './src/content/blog', pattern: '**/*.mdx' }),
  schema: ({ image }) =>
    z.object({
      titulo: z.string(),
      resumo: z.string(),
      data: z.coerce.date(),
      categoria: z.string(),
      tags: z.array(z.string()).default([]),
      descricaoSeo: z.string().max(160).optional(),
      capa: image().optional(),
      rascunho: z.boolean().default(false),
    }),
});

const diario = defineCollection({
  loader: glob({ base: './src/content/diario', pattern: '**/*.mdx' }),
  schema: ({ image }) =>
    z.object({
      // Referência validada: apontar para projeto inexistente quebra o build.
      projeto: reference('projetos'),
      titulo: z.string(),
      data: z.coerce.date(),
      metricas: z
        .array(
          z.object({
            rotulo: z.string(),
            valor: z.string(),
            percentual: z.number().min(0).max(100),
          })
        )
        .default([]),
    }),
});

Dois detalhes que economizam tempo depois:

O reference('projetos') liga esta entrada de diário ao projeto e valida no build. Se eu renomear um projeto e esquecer de atualizar as entradas, o build avisa em vez de gerar uma página órfã.

O descricaoSeo com .max(160) impede que eu escreva uma meta description que o Google vai cortar no meio. O limite vira erro de build, não algo para lembrar.

O rascunho que evitou um problema real

O campo rascunho: true esconde o conteúdo em produção mas mantém em desenvolvimento:

src/lib/conteudo.ts
const incluirRascunhos = import.meta.env.DEV;

function visivel(entrada: { data: { rascunho: boolean } }): boolean {
  return incluirRascunhos || !entrada.data.rascunho;
}

Isso nasceu como conveniência e virou salvaguarda. Os layouts vieram com posts de exemplo inventados — artigos sobre Kafka, projetos de kernel em Rust, experiências em empresas onde eu nunca trabalhei. No momento em que o site passou a ter meu nome real, esse texto deixou de ser placeholder inofensivo e virou afirmação falsa sobre mim.

Marcar tudo como rascunho resolveu na hora: eu seguia revisando o layout com conteúdo na tela, e o build de produção saía sem nenhuma alegação que eu não pudesse sustentar.

Busca client-side com Pagefind

Busca costuma ser o motivo pelo qual um site estático deixa de ser estático. Algolia cobra, Elasticsearch pede servidor, e ambos são desproporcionais para um blog pessoal.

O Pagefind resolve invertendo o problema: em vez de consultar um índice remoto, ele gera o índice a partir do HTML já construído e o navegador baixa só os fragmentos que a consulta precisa.

package.json
{
  "scripts": {
    "build": "astro build && pagefind --site dist"
  }
}

A integração no markup é um atributo. data-pagefind-body no <main> marca o que indexar; data-pagefind-ignore no header e no rodapé evita que o menu apareça como resultado em toda página.

Hoje são 11 páginas e 2.576 palavras indexadas. O índice fica em dist/pagefind e é servido como qualquer outro arquivo estático — sem processo rodando, sem mensalidade.

Capas sociais geradas no build

Link sem imagem tem menos clique no LinkedIn e no WhatsApp. Mas abrir editor de imagem a cada post é atrito que garante que eu vou parar de fazer.

Resolvi com um endpoint que gera PNG em tempo de build, usando satori para transformar layout em SVG e resvg para rasterizar:

src/pages/og/[...slug].png.ts
export const getStaticPaths = (async () => {
  const posts = await getPosts();

  return [
    ...posts.map((post) => ({
      params: { slug: post.id },
      props: { titulo: post.data.titulo, categoria: post.data.categoria },
    })),
    { params: { slug: 'default' }, props: { titulo: site.descricao } },
  ];
}) satisfies GetStaticPaths;

export const GET: APIRoute = async ({ props }) => {
  const png = await gerarCardSocial({
    titulo: props.titulo as string,
    categoria: props.categoria as string | undefined,
  });

  return new Response(new Uint8Array(png), {
    headers: { 'Content-Type': 'image/png' },
  });
};

Um tropeço no caminho: o satori não lê woff2, que é o único formato que os pacotes @fontsource-variable fornecem. Tive que versionar dois arquivos TTF em src/assets/fonts/og/ só para a geração das capas. Custou 15 minutos de confusão até eu ler a mensagem de erro com atenção.

O número que eu mais queria ver

O bundle JavaScript em dist/_astro ficou em 0 KB. Não há framework no cliente. O único JavaScript do site são 2,8 KB inline, divididos entre o menu mobile, o botão de copiar código e o formulário que abre o WhatsApp.

A home tem 22,4 KB de HTML, que viram 5,3 KB depois do gzip. Isso cabe em um único pacote TCP.

AstroMDXPagefindSEO