Fazendo round-trip sem perdas do frontmatter YAML com a API de nós do Psych
Eu estava construindo o editor de metadados do novo editor de posts do blog-manager, um painel onde edito o título, a descrição, a categoria, as tags e outros campos de um post, e isso escreve de volta no arquivo Markdown no GitHub. O escopo do issue era claro sobre o objetivo: fazer parse do frontmatter YAML, deixar eu editar alguns campos, serializar de volta, e o diff tinha que ficar pequeno. Um commit vindo desse editor nunca poderia corromper um post.
O issue também sugeria como construir isso: fazer parse com YAML.safe_load (como o scanner existente já faz), depois serializar com uma ordem de chaves estável, campos conhecidos do schema primeiro. Quase fiz exatamente isso. Aí eu realmente olhei os arquivos.
O problema com a abordagem óbvia
Rodei um grep no frontmatter de todos os 166 posts do vdaluz.com e encontrei o mesmo campo lógico escrito de três formas diferentes, dependendo do post:
title: 'Surrounded by Noise: How to Find Clarity When Everything Feels Urgent'
title: 'Deploying Immich for self-hosted photos: NAS for the library, SSD for the hot path'
category: 'Productivity'
category: Infrastructure
author: 'Victor Da Luz'
author: Victor Da Luz
Aspas duplas, aspas simples, sem aspas, dependendo do que eu quis fazer quando escrevi cada post ao longo dos anos. Um round trip via Hash, YAML.safe_load na entrada, Hash#to_yaml na saída, joga tudo isso fora. O dumper YAML do Ruby escolhe um estilo e aplica em tudo. Na primeira vez que alguém editasse qualquer campo de qualquer post, o bloco inteiro de frontmatter seria reformatado. Isso é o oposto de “os diffs ficam mínimos”, e é o próprio critério de sucesso declarado do issue contradizendo a implementação que ele mesmo sugeria.
O que eu construí em vez disso
A biblioteca YAML do Ruby (Psych) tem uma API de nível mais baixo que quase ninguém toca: Psych.parse_stream retorna uma árvore de nós completa (Psych::Nodes::Scalar, Sequence, Mapping) em vez de um Hash simples. Cada nó escalar lembra seu próprio estilo de aspas. Se eu só alterar o nó do campo que estou de fato mudando e deixar todos os outros intactos, reemitir a árvore preserva a formatação original de tudo que eu não toquei.
stream = Psych.parse_stream(yaml_text)
mapping = stream.children.first.children.first
# find the "author" key/value pair, mutate only its value node
val_node.value = "New Author"
stream.yaml(nil, line_width: -1)
O line_width: -1 me custou um minuto pra descobrir. Sem ele, o emissor do Psych quebra linha de escalares longos (títulos, descrições) em torno de 80 colunas por padrão, o que reformata todo post com título longo mesmo sem nenhum campo editado. Essa opção sozinha foi a diferença entre “parece que funciona” e realmente passar num teste de round trip de verdade.
Validando antes de escrever uma linha de código de produção
Antes de me comprometer com esse design, escrevi um script descartável e rodei contra os 166 posts em produção: parse, reemite, compara com o original. 155 dos 166 voltaram byte a byte idênticos com zero edições. Os outros 11 diferiam por exatamente uma coisa: um punhado de posts mais antigos escrevia tags: numa linha própria com o array numa linha de continuação (às vezes um bloco multi-linha grande com vírgula à direita). Meu serializador colapsa isso numa linha só. Ainda é YAML válido, semanticamente idêntico, e estável sob um segundo round trip. Confirmei que serialize(serialize(x)) == serialize(x) para os 11, então é um ajuste único, não uma deriva. Zero quebrados.
Isso me deu confiança para construir a coisa de verdade em vez de uma versão baseada em Hash que eu teria que desfazer depois.
Decisões de design
Acabei com uma classificação de três vias para cada campo do frontmatter, não só “conhecido vs. desconhecido”: campos editáveis que esse painel escreve (title, description, pubDate, category, author, tags, affiliates); campos conhecidos de passagem, preservados mas cuja posição importa para inserir novas chaves corretamente (lane, heroImage, heroImageCredit); e campos desconhecidos, preservados ao pé da letra, sempre depois dos conhecidos.
A categoria do meio existe por causa do lane, exigido pelo schema zod do Astro mas que esse editor nunca toca, e heroImage/heroImageCredit pertencem a um painel de imagem de capa totalmente separado. Se eu só rastreasse “campos editáveis conhecidos”, inserir uma chave nova (digamos, um post que nunca teve affiliates antes) acabaria posicionando ela no lugar errado em relação a campos que eu nem tenho permissão de escrever.
Também decidi não adicionar uma coluna nova pra guardar o texto bruto do frontmatter no modelo de rascunho. A validação de campo editável e o serializador de round trip são úteis por si só (e é isso que a varredura de fixtures do CI testa), mas a reescrita byte a byte só importa mesmo no momento do commit, quando o código precisa do conteúdo atual do arquivo em produção de qualquer forma, para não sobrescrever a edição de outra pessoa. Isso é problema de outro issue, não deste.
O que me surpreendeu
Uma pegadinha pequena do YAML: Psych.parse(yaml_text) retorna um nó Mapping diretamente, mas você não pode chamar .to_yaml num mapping solto, o emissor espera um documento ou stream completo, senão lança expected STREAM-START. Psych.parse_stream te dá a árvore inteira e simplesmente funciona. Levei algumas tentativas frustradas até chegar nisso.
A outra surpresa foi boa: quando você altera o valor de um campo existente e deixa o atributo style dele intocado, o emissor do Psych é esperto o suficiente pra cair de volta em aspas automaticamente se o novo valor não fosse seguro como YAML puro. Testei isso editando uma categoria pra "Security & Privacy: A Talk" (dois-pontos seguido de espaço é especial em YAML) e simplesmente funcionou, reaplicou aspas automaticamente, e voltou no parse exatamente com a string que eu defini. Não precisei escrever nenhuma lógica de escape sozinho.
O que vem a seguir
O serializador e o painel de metadados estão prontos e testados (varredura de fixtures, testes unitários específicos, e uma passada de verdade no navegador pelo fluxo de autosave, incluindo o caminho de rejeição de metadados inválidos). A próxima peça é o fluxo de commit, pegar as edições de um rascunho e realmente escrevê-las no arquivo em produção no GitHub, que é onde esse serviço de frontmatter prova o próprio valor de verdade.
Leitura relacionada
O que acontece quando um job faz broadcast para ninguém
Fechando o loop da imagem de destaque: frontmatter só por inserção, uma guarda que pegou divergência real na primeira execução, e um broadcast sem ouvinte.
Uma correção de desvio de documentação que não foi tão chata quanto parecia
Três itens da auditoria que cada um virou outra coisa: uma alegação meio corrigida, uma recuperação de senha silenciosamente morta, e um e-mail de staging que apontaria para produção.
Um 500 escondido dentro das rotas isoladas de uma engine montada
O painel de jobs retornava um 500 em vez de uma página de login: helpers de rota sem qualificação resolvem contra a engine, não contra a aplicação. Uma linha, mais o gêmeo dormente dela.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, 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 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 maisProton 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 mais