Impedindo que a lacuna de posts sem imagem de destaque volte (uma trava, não um lembrete)
Um tempo atrás fiz uma tarefa chata e satisfatória: encontrei 80 posts do blog no meu site sem imagem de destaque e dei uma imagem para cada um deles. Parecia progresso. Não era, não de verdade. Uma limpeza pontual resolve o hoje e não promete nada sobre o amanhã, e alguns meses depois fui verificar e descobri que a lacuna tinha voltado silenciosamente. Quatro posts publicados, sem imagem de destaque.
Esta é a história da segunda correção, a que realmente se sustenta.
Por que a lacuna continuava voltando
Meu blog é um site Astro. Os metadados dos posts ficam no frontmatter, validados por um schema Zod. Aqui está a linha relevante:
heroImage: z.string().optional(),
Opcional. Essa palavra é o problema todo. Meu fluxo de publicação verifica se os campos que um post tem são válidos, mas um campo opcional ausente é válido por definição. Então um post sem imagem de destaque passa por toda verificação, é commitado, faz deploy e vai ao ar parecendo um pouco nu. Nada nunca reclama. A única forma de eu descobrir foi indo lá e contando.
A correção óbvia é tornar o campo obrigatório:
heroImage: z.string(), // tempting, wrong
Não fiz isso, e quero explicar por quê, porque é a decisão da qual todo o resto do trabalho depende. Um campo obrigatório quebra o build para todo post sem imagem de destaque, incluindo rascunhos que ainda estou escrevendo, datados da semana que vem e longe de prontos. Eu ficaria bloqueado por uma imagem antes mesmo de terminar o texto. O schema não consegue diferenciar “publicado e quebrado” de “ainda não pronto”. Eu precisava de uma verificação que conseguisse.
A trava
A distinção que realmente me importa é: este post está no ar? Um post está no ar quando o pubDate é hoje ou antes. Posts com data futura são rascunhos e não são da conta da trava. Esse único filtro é toda a ideia.
const today = new Date().toISOString().slice(0, 10);
for (const file of files) {
const { data } = matter(await readFile(join(BLOG_DIR, file), 'utf8'));
const pubDate = String(data.pubDate ?? '').slice(0, 10);
// Only enforce on published posts; drafts are exempt.
if (!pubDate || pubDate > today) continue;
const hero = typeof data.heroImage === 'string' ? data.heroImage.trim() : '';
if (!hero) violations.push(file.replace(/\.md$/, ''));
}
Repare no .trim(). O schema aceita uma string vazia, então heroImage: "" passaria numa verificação ingênua de presença, mesmo estando tão quebrado quanto não ter imagem de destaque nenhuma. Trate vazio como ausente.
Ela roda como um hook de pre-commit e de novo no CI. Localmente, ela impede que um post sem imagem de destaque seja commitado. No CI, é o backup para qualquer coisa que escape do hook. O mesmo script nos dois lugares.
A parte que eu quase errei
Meu CI roda os hooks contra o repositório inteiro, todo arquivo, toda vez (pre-commit run --all-files). É um bom padrão e tem um lado afiado: uma trava recém-criada, apontada para um corpus com violações preexistentes, falha no primeiro dia. Eu já tinha um hook que escapava disso. Minha verificação de palavras banidas é explicitamente pulada no CI exatamente por esse motivo, porque escanear o histórico inteiro revela formulações antigas com as quais já fiz as pazes.
Eu poderia ter copiado isso e colocado um SKIP na trava nova. Não fiz, e essa é a parte que eu sublinharia para quem for construir algo parecido. Pular no CI significaria lançar uma trava que não trava nada. Os quatro posts quebrados ficariam ali, permanentemente isentos, e a verificação só pegaria erros novos enquanto mente sobre o estado atual. Então fiz a coisa menos esperta: corrigi os quatro posts primeiro, para que a trava pudesse rodar contra tudo com zero exceções e passar de verdade. Uma trava com asterisco não é uma trava.
O auxiliar, e uma surpresa genuína
Para corrigir os quatro (e para tornar definir uma imagem de destaque algo de uma linha só daqui em diante), escrevi um pequeno auxiliar. Dê a ele um slug e uma imagem, de uma URL ou um arquivo local, e ele baixa a imagem, escreve o webp irmão com as mesmas configurações do sharp que o build já usa, e insere a linha no frontmatter:
node scripts/set-hero-image.mjs <slug> <image-url-or-path>
Deliberadamente mantive isso como uma edição de texto, não um parse-e-reescreve. Fazer o frontmatter ir e voltar por uma biblioteca YAML teria reformatado o estilo das aspas e reordenado as chaves nos quatro arquivos, enterrando uma mudança de uma linha num diff barulhento. Uma inserção direcionada mantém o diff restrito à única linha que mudou.
A surpresa foi a contagem. Eu esperava dois posts quebrados e encontrei quatro. Um dos dois extras nem sequer estava commitado. Era um rascunho parado na minha working tree, nunca enviado, com um pubDate retroativo que fazia a trava tratá-lo como se estivesse no ar. Minha primeira reação foi que a trava tinha um falso positivo. Minha segunda reação, melhor, foi que a trava estava certa e eu estava errado: um post datado no passado está dizendo ao mundo que está publicado, e se eu algum dia commitar isso, ele vai ao ar sem imagem de destaque. A ferramenta pegou um erro que eu ainda nem tinha cometido. Esse é o tipo bom de chato.
O que eu levo disso
O padrão que eu continuo reaprendendo: uma checklist te diz o que você deveria fazer, uma trava torna a coisa errada impossível de commitar. Também atualizei meu fluxo de publicação para definir uma imagem de destaque como uma etapa, e isso é genuinamente útil, mas é um lembrete para o meu eu futuro, e meu eu futuro é esquecido. A peça que sustenta tudo são as doze linhas que quebram o build. O resto é conveniência.
E quando você adiciona uma trava a uma base de código que já tem violações, resista à tentação de pular. Limpe as violações primeiro para que a trava possa ser absoluta. O momento em que ela ganha uma exceção é o momento em que ela deixa de significar qualquer coisa.
Leitura relacionada
O blog que não conseguia publicar num cronograma
Um design de pipeline em lote que começou com uma checagem de cinco minutos na base do sistema: posts com data futura não ficavam escondidos, viravam 404, e nada nunca reconstruía o site.
O runbook que mentiu duas vezes
Uma correção de uma linha na documentação virou três significados para um mesmo hostname, um diretório de runner renomeado, e uma automação que reintroduziu o bug que deveria prevenir.
Fazendo backfill da realidade num rastreador de sindicação
O prod dizia zero posts no Medium; as rake tasks resolveram isso em minutos. Depois o reconciler quebrou em todo lugar, um dashboard foi raspado direto do DOM, e o CI falhou de três formas diferentes.
Você também pode achar útil
Proton Drive
Armazenamento em nuvem criptografado, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton Mail
E-mail criptografado de ponta a ponta, com arquitetura de acesso zero.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba mais