Saltar al contenido
Development

Cómo hacer un round trip sin pérdidas del frontmatter YAML con la API de nodos de Psych

Por Victor Da Luz
railsrubyyamldev-logblog-manager

Estaba construyendo el editor de metadatos para el nuevo editor de posts de blog-manager, un panel donde puedo editar el título, la descripción, la categoría, las etiquetas y demás campos de un post, y que escribe los cambios de vuelta al archivo Markdown en GitHub. El spec del issue era claro sobre el objetivo: parsear el frontmatter YAML, permitir editar algunos campos, serializarlo de vuelta, y que el diff se mantuviera chico. Un commit de este editor nunca debía corromper un post.

El issue también sugería cómo construirlo: parsear con YAML.safe_load (como hace el scanner existente), y después serializar con un orden de claves estable, con los campos conocidos del esquema primero. Casi lo hago tal cual. Después me puse a mirar los archivos de verdad.

El problema con el enfoque obvio

Hice grep del frontmatter en los 166 posts de vdaluz.com y encontré el mismo campo lógico escrito de tres formas distintas según qué post se mirara:

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

Con comillas dobles, con comillas simples, sin comillas, según lo que se me ocurriera al escribir cada post a lo largo de los años. Un round trip con Hash, YAML.safe_load de entrada, Hash#to_yaml de salida, tira todo eso a la basura. El dumper de YAML de Ruby elige un solo estilo y lo aplica en todas partes. La primera vez que alguien editara cualquier campo de cualquier post, todo el bloque de frontmatter se reformatearía. Eso es lo opuesto a “los diffs se mantienen mínimos,” y es el propio criterio de éxito del issue contradiciendo su propia implementación sugerida.

Lo que construí en su lugar

La biblioteca YAML de Ruby (Psych) tiene una API de más bajo nivel que casi nadie toca: Psych.parse_stream devuelve un árbol de nodos completo (Psych::Nodes::Scalar, Sequence, Mapping) en vez de un Hash plano. Cada nodo escalar recuerda su propio estilo de comillas. Si solo modifico el nodo del campo que estoy cambiando y dejo todos los demás nodos intactos, volver a emitir el árbol conserva el formato original de todo lo que no toqué.

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)

Encontrar line_width: -1 me tomó un minuto. Sin esa opción, el emisor de Psych envuelve los escalares largos (títulos, descripciones) a unas 80 columnas por defecto, lo cual reformatea cualquier post con un título largo aunque no se edite ningún campo. Esa única opción fue la diferencia entre “parece que funciona” y pasar de verdad una prueba real de round trip.

Validarlo antes de escribir una sola línea de código de producción

Antes de comprometerme con este diseño escribí un script descartable y lo corrí contra los 166 posts en vivo: parsear, volver a emitir, comparar el diff contra el original. 155 de 166 volvieron byte por byte idénticos sin ninguna edición. Los otros 11 diferían exactamente en una cosa: un puñado de posts más viejos escribían tags: en su propia línea con el arreglo en una línea de continuación (a veces un bloque grande de varias líneas con una coma final). Mi serializador colapsa eso en una sola línea. Sigue siendo YAML válido, semánticamente idéntico, y estable en un segundo round trip, comprobé que serialize(serialize(x)) == serialize(x) para los 11, así que es un ajuste de una sola vez, no una deriva. Cero rotos.

Eso me dio la confianza para construir la versión real en vez de una basada en Hash que después hubiera tenido que revertir.

Decisiones de diseño

Terminé con una clasificación en tres categorías de cada campo del frontmatter, no solo “conocido vs. desconocido”: campos editables que este panel escribe (title, description, pubDate, category, author, tags, affiliates); campos conocidos de paso, que se preservan pero cuya posición importa para insertar nuevas claves correctamente (lane, heroImage, heroImageCredit); y campos desconocidos, preservados tal cual, siempre después de los conocidos.

La categoría del medio existe por lane, lo exige el esquema zod de Astro pero este editor nunca lo toca, y heroImage/heroImageCredit pertenecen por completo a un panel de imagen destacada separado. Si solo hubiera rastreado los “campos editables conocidos,” insertar una clave nueva (digamos, un post que nunca tuvo affiliates antes) la hubiera colocado en el lugar equivocado respecto a campos que ni siquiera tengo permitido escribir.

También decidí no agregar una columna nueva para guardar el texto crudo del frontmatter en el modelo de borrador. La validación de campos editables y el serializador de round trip son útiles por sí solos (y eso es lo que prueba el barrido de fixtures de CI), pero la reescritura que preserva los bytes solo importa en el momento del commit, cuando el código de todas formas necesita el contenido actual del archivo en vivo para no pisar la edición de otra persona. Eso es problema de otro issue, no de este.

Lo que me sorprendió

Un pequeño tropiezo de YAML: Psych.parse(yaml_text) devuelve un nodo Mapping directamente, pero no se puede llamar .to_yaml sobre un mapping suelto, el emisor quiere un wrapper completo de documento/stream o lanza expected STREAM-START. Psych.parse_stream en cambio entrega el árbol completo y simplemente funciona. Me tomó varios experimentos fallidos llegar a eso.

La otra sorpresa fue agradable: cuando modifico el valor de un campo existente y dejo su atributo style intacto, el emisor de Psych es lo bastante inteligente como para recurrir a comillas automáticamente si el valor nuevo no sería seguro como YAML plano. Probé esto editando una categoría a "Security & Privacy: A Talk" (los dos puntos seguidos de espacio son especiales en YAML) y simplemente funcionó, se volvió a entrecomillar automáticamente, y al parsear de vuelta dio exactamente la cadena que puse. No tuve que escribir nada de esa lógica de escape yo mismo.

Qué sigue

El serializador y el panel de metadatos están terminados y probados (barrido de fixtures, pruebas unitarias específicas, y una pasada real en navegador por el flujo de autoguardado, incluyendo el camino de rechazo de metadatos inválidos). La siguiente pieza es el flujo de commit, tomar las ediciones de un borrador y escribirlas de verdad en el archivo en vivo de GitHub, que es donde este servicio de frontmatter realmente demuestra su valor.

Lecturas relacionadas

Development

El botón de commit del editor es un botón de deploy

Confirmar un borrador a main despliega el blog automáticamente. En cuanto eso quedó claro, sync vs. async dejó de ser una cuestión de estilo, más el caso especial de afiliado heredado que un validador nuevo casi rompió.

Leer