Skip to content
Development

Translation mode for blog-manager

By Victor Da Luz
railsrubyi18ndev-logblog-manager

I’ve been slowly bringing a Spanish translation to vdaluz.com, one post at a time, by hand - copy the English markdown, translate it, paste it into a new file under src/content/blog/es/, commit. It works but it’s tedious enough that I kept putting it off. This issue was about giving blog-manager an actual editor for that instead of raw file surgery.

The shape of the problem is different from the English editor blog-manager already has. English posts are the source of truth - one file, one set of fields, commit and done. A translation is a second file that shares some of that post’s metadata (pubDate, category, tags, author, hero image) but has its own title, description, and body. Those shared fields shouldn’t be independently editable on the translation - if I fix a typo in an English post’s category, I want that to flow into the Spanish version automatically the next time I touch it, not require me to remember to edit two files.

What I built

A post_translations table with one row per post-with-a-Spanish-file, tracking two shas: file_sha (the es/ file’s current commit) and source_file_sha (the English file’s sha as of the last translation commit). Comparing the two gives a status - :unverified if never committed, :stale if English has moved since, :present if in sync. A post with no row at all is :missing. The invariant I wanted: a row exists if and only if that post’s es/ file currently exists on GitHub - so the scanner reconciles this on every scan, not just at commit time.

The editor itself is a split view - English on the left as read-only reference (raw/rendered toggle, reusing the existing preview renderer), the Spanish draft on the right using the same autosave/draft-frame machinery the English editor already has. The shared fields show up as hidden inputs mirroring the English values, purely so the existing draft controller’s target lookups don’t blow up - they’re never actually read back into the commit.

Decisions I made and why

I scoped this down partway through planning. The original issue also wanted a “your English edits changed a shared field, want to push that to the translation too?” prompt - a proactive sync nudge. I split that off into a follow-up instead of building it now, because it’s a genuinely separate UX problem (when do you nag, what if there are multiple translations someday) and I didn’t want to block the core editing flow on getting that right. Core scope: open a translation, edit it, commit it, see its status. That’s it.

I also treated author as a shared field rather than something a translator might override. My blog only has one author (me), so there was no real case for per-locale attribution, and treating it as shared keeps the “translations don’t own metadata” story simpler.

What surprised me / didn’t work

The interesting bug wasn’t in the new code - it was a bug I already knew about from the editor work that I copied without meaning to. Frontmatter#update! in this codebase doesn’t merge a value into an existing YAML node, it rebuilds the node, which resets its emission style. If a post’s tags field is written as a YAML block list and you call update!("tags" => same_array), it comes back as a flow-style array even though the value never changed. The English committer already has a guard for this (diff first, only pass what’s actually different). My first pass at the translation committer’s refresh_shared_fields didn’t have that guard, so every single translation commit would’ve silently reformatted the es/ file’s tags and hero credit block, whether or not English had actually changed. Code review caught it before merge; no test caught it, because no fixture happened to already have synced shared fields going in. I wrote a knowledge-base note about it since it’s exactly the kind of thing that’ll bite a third call site someday if nobody’s looking for it.

The other one that got me: I initially stamped source_file_sha from post.file_sha (the DB’s cached value) instead of the English file’s sha I’d just fetched fresh in the same request. If the blog hadn’t been rescanned since English last changed, that’s a stale value being written into the row meant to prove freshness - a translation could read back as :stale immediately after being refreshed, which defeats the whole point of the status.

Also just a plain gap I ran into during verification, not a bug: dev has no GitHub token at all configured for this blog, so I couldn’t actually click through the commit flow in a browser this time - only unit/integration tests exercise that path. Worth fixing at some point, but not blocking here.

What’s next

The shared-field propagation follow-up (push a shared-field change from English into an existing translation without requiring a full translation re-edit) is filed and sitting in the backlog, low priority. No other blogs have an es/ directory yet, so all of this is currently a no-op in production until I actually start translating something.

Related reading