Propagación de campos compartidos hacia las traducciones al español
Quedaba por cerrar un pequeño vacío que había dejado el modo de traducción. Al confirmar una edición en el post en inglés (cambiar la fecha de publicación, cambiar una categoría, agregar una etiqueta), la copia de esos mismos campos en la traducción al español queda desactualizada en silencio. Nada se rompe, pero el archivo es/ pasa a estar en desacuerdo con el inglés en vivo hasta que alguien abre el editor de vista dividida y vuelve a confirmar una traducción completa, lo cual es una acción más pesada de lo que la situación requiere.
Este seguimiento se separó específicamente para arreglar eso: después de un commit que toca un campo compartido (pubDate, category, tags, author, hero), ofrecer una acción liviana de un solo clic, “sincronizar esto al español,” que sobrescribe solo esos campos en el archivo es en vivo, dejando intactos el título, la descripción y el cuerpo traducidos.
Lo que se construyó
El camino de commit en inglés ya calculaba internamente un hash changed para decidir qué claves del frontmatter reescribir, solo que después descartaba el resultado. Se agregó un miembro changed_fields a su Result struct y se empezó a devolverlo. Es un cambio de una línea expuesto a través de un struct que ya existía.
Para la escritura real de propagación, no se quería una clase de commit completamente nueva que duplicara la lógica de diffing de frontmatter del committer de traducción. Ese committer ya tenía un método privado refresh_shared_fields que hace exactamente el diff necesario (resincroniza los campos compartidos en cada commit de traducción, por si la traducción se había desviado). Así que se agregó un segundo punto de entrada público, #propagate_shared_fields(post, message:), que reutiliza el mismo código privado de diffing pero se salta dos cosas que sí hace un commit de traducción real: no toca el borrador, el título ni la descripción, y, algo crítico, no actualiza post_translations.source_file_sha. Esa columna es la que marca una traducción como “confirmada vigente”, la propagación es una conveniencia, no una confirmación de que el propio texto traducido fue revisado de nuevo.
El lado del disparador resultó ser dos problemas distintos disfrazados de la misma interfaz. Las ediciones de campos de texto pasan por la acción de commit, que es una solicitud síncrona normal, ahí basta con verificar si changed_fields se superpone con la lista de campos compartidos y redirigir con un parámetro de consulta que hace que la página de detalle muestre un banner de oferta. Pero los commits de imagen hero pasan por un job en segundo plano, que corre después de que la solicitud ya terminó y empuja su resultado de vuelta por un Turbo Stream. Ya no queda ninguna solicitud a la cual adjuntarle un parámetro de consulta para cuando el commit efectivamente llega. Así que el camino de hero transmite el mismo partial de banner a un div marcador de posición en la página de detalle en su lugar, y cualquier commit de hero que tenga éxito mientras exista una traducción siempre ofrece propagación (un commit de hero es por definición un cambio de campo compartido, así que no hay diff sobre el cual filtrarlo).
Una decisión que dio un rodeo
El primer instinto fue poner el div objetivo del banner dentro del partial existente de la tarjeta del post, ya que ese partial de todas formas se vuelve a renderizar por completo después de cada commit de hero vía una llamada de broadcast. Parecía conveniente, la tarjeta ya se estaba reemplazando, así que el banner vendría de paso.
Eso habría sido un bug real. El broadcast reemplaza la tarjeta entera con un render fresco al que solo se le pasa { post: post } como locals, sin flag show, sin estado de oferta. Cualquier referencia a ese local dentro del partial de la tarjeta lanzaría una excepción en cada commit de hero, éxito o no, ya que la tarjeta también se renderiza en el índice y en otras páginas donde el banner no tiene ningún sentido. El marcador de posición se movió a show.html.erb, fuera de la tarjeta, así queda como un objetivo de broadcast completamente separado que solo toca el camino de éxito de ese job en particular.
Lo que sorprendió
Frontmatter#update! siempre reconstruye cualquier nodo de valor que toca, incluso para volver a escribir exactamente el mismo valor, por eso ambos committers existentes hacen diff antes de escribir en vez de escribir cada campo sin condición (un array de tags en estilo bloque que no se toca de otro modo terminaría colapsado en silencio a estilo flow en cada commit). La escritura de propagación necesitaba la misma disciplina, así que se agregó una verificación real de no-op: parsear el frontmatter es en vivo desde cero, pasarlo por el diff, y solo emitir un commit si el YAML resultante realmente difiere. Un botón de “sincronizar” que escribe un commit idéntico cada vez que se hace clic sería su propio tipo de bug.
Probarlo sin tocar producción
Esta app escribe directamente a GitHub vía la Contents API, un clic de test real en “Sync to Spanish” contra un blog real empujaría un commit real y dispararía un deploy real. La base de datos de desarrollo no tiene github_token configurado para ninguno de los dos blogs, así que se pudo manejar todo el flujo en un navegador real (login, render del banner, ausencia del banner sin el parámetro de consulta, clic en el botón) y verlo fallar limpio con un AuthError justo en el punto donde de otro modo escribiría, que es exactamente el límite seguro que se quería ejercitar. La lógica real de escritura a GitHub (el diffing, la detección de no-op, el invariante de “no tocar source_file_sha”) está cubierta por tests de servicio y de controlador usando el patrón de cliente falso ya establecido en este código, y el broadcast asíncrono está cubierto por un test de job que verifica que el partial de propagación realmente llega a su objetivo de Turbo Stream.
Qué sigue
Nada quedó encolado directamente a partir de este. Los borradores de traducción generados por IA y la verificación de publicación en Dev.to siguen en el backlog de la superficie de traducción/editor en general.
Lecturas relacionadas
Modo de traducción para blog-manager
Un editor de vista dividida donde las traducciones comparten metadata pero son dueñas de su propia prosa, un modelo de obsolescencia de dos shas, y un bug de formato que copié de mi propio código anterior.
Construyendo un escaneo de posts consciente de idioma para blog-manager
Agregando la plomería del pipeline de i18n a una app Rails de sindicación de blogs, y el bug de migración parcial que mi propia revisión de código atrapó antes de publicarlo.
El bug de normalización que solo aparece con etiquetas hechas de nada
Un normalizador basado en strip se topa con una etiqueta de puro signo de puntuación: string vacío como clave de hash, sustitución de etiqueta equivocada, y un autocompletado que hace match con todo. Tres síntomas, una sola causa raíz.