Skip to content
Development

Shared-field propagation to Spanish translations

By Victor Da Luz
railsrubyi18ndev-logblog-manager

I wanted to close a small gap left over from translation mode. When I commit an edit to the English post - change the pub date, swap a category, add a tag - the Spanish translation’s copy of those same fields silently goes stale. Nothing breaks, but the es/ file now disagrees with the live English one until someone opens the split-view editor and re-commits a full translation, which is a heavier action than the situation calls for.

This follow-up was split off specifically to fix that: after a commit that touches a shared field (pubDate, category, tags, author, hero), offer a lightweight one-click “sync this to Spanish” action that overwrites only those fields in the live es file, leaving the translated title/description/body untouched.

What I built

The English commit path already computed a changed hash internally to decide which frontmatter keys to rewrite - it just threw the result away afterward. I added a changed_fields member to its Result struct and started returning it. That’s a one-line change surfaced through a struct that already existed.

For the actual propagation write, I didn’t want a whole new committer class duplicating the translation committer’s frontmatter-diffing logic. It already had a private refresh_shared_fields method that does exactly the diff I needed (it resyncs shared fields on every translation commit, in case the translation had drifted). So I added a second public entry point, #propagate_shared_fields(post, message:), that reuses the same private diffing code but skips two things a real translation commit does: it doesn’t touch the draft/title/description, and critically, it doesn’t update post_translations.source_file_sha. That column is what marks a translation “confirmed current” - propagation is a convenience, not a confirmation that the translated prose itself was re-checked.

The trigger side turned out to be two different problems wearing the same UI. Text-field edits go through the commit action, which is a normal synchronous request - I can just check whether changed_fields overlaps the shared-field list and redirect with a query param that makes the show page render an offer banner. But hero image commits go through a background job, which runs after the request has already finished and pushes its result back over a Turbo Stream. There’s no request left to attach a query param to by the time the commit actually lands. So the hero path broadcasts the same banner partial to a placeholder div on the show page instead, and any hero commit that succeeds while a translation exists just always offers propagation (a hero commit is definitionally a shared-field change, so there’s no diff to gate it on).

A decision that took a detour

My first instinct was to put the banner’s target div inside the existing post card partial, since that’s already re-rendered wholesale after every hero commit via a broadcast call. It seemed convenient - the card was already getting swapped out, so the banner would just come along for the ride.

That would have been a real bug. The broadcast replaces the entire card with a fresh render that only ever passes it { post: post } as locals - no show flag, no offer state. Any reference to that local inside the card partial would throw on every single hero commit, success or not, since the card gets rendered on the index and other pages too where the banner has no business showing at all. I moved the placeholder to show.html.erb, outside the card, so it’s a completely separate broadcast target that only that one job’s success path ever touches.

What surprised me

Frontmatter#update! always rebuilds whatever value node it touches, even to write back the exact same value - which is why both existing committers diff before writing rather than writing every field unconditionally (an untouched block-style tags array would otherwise get silently collapsed to flow style on every commit). My propagation write needed the same discipline, so I added a real no-op check: parse the live es frontmatter fresh, run it through the diff, and only issue a commit if the resulting YAML actually differs. A “sync” button that writes an identical commit every time it’s clicked would be its own kind of bug.

Testing it without touching production

This app writes directly to GitHub via the Contents API - a real test click on “Sync to Spanish” against a real blog would push an actual commit and trigger an actual deploy. My dev database has no github_token configured for either blog, so I could drive the whole flow in a real browser (login, banner render, banner absence without the query param, clicking the button) and watch it fail cleanly with an AuthError at the point where it would otherwise write - which is exactly the safe boundary I wanted to exercise. The actual GitHub-write logic (the diffing, the no-op detection, the “don’t touch source_file_sha” invariant) is covered by service and controller tests using the fake client pattern already established in this codebase, and the async broadcast is covered by a job test that asserts the propagation partial actually reaches its Turbo Stream target.

What’s next

Nothing queued directly off this one. AI-generated translation drafts and Dev.to publishing verification are still sitting in the backlog for the translation/editor surface generally.

Related reading

Development

Translation mode for blog-manager

A split-view editor where translations share metadata but own their prose, a two-sha staleness model, and a formatting bug I copied from my own earlier code.

Read