Losslessly round-tripping YAML frontmatter with Psych's node API
I was building the metadata editor for blog-manager’s new post editor - a panel where I can edit a post’s title, description, category, tags, and so on, and have it write back to the Markdown file on GitHub. The issue spec was clear about the goal: parse the YAML frontmatter, let me edit some fields, serialize it back out, and the diff should stay small. A commit from this editor should never corrupt a post.
The issue also suggested how to build it: parse with YAML.safe_load (like the existing scanner does), then serialize with a stable key order, known schema fields first. I almost just did that. Then I actually looked at the files.
The problem with the obvious approach
I grepped the frontmatter across all 166 posts on vdaluz.com and found the same logical field written three different ways depending on which post you looked at:
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
Double-quoted, single-quoted, bare, depending on whatever I felt like when I wrote each post over the years. A Hash round trip - YAML.safe_load in, Hash#to_yaml out - throws all of that away. Ruby’s YAML dumper picks one style and applies it everywhere. The very first time someone edited any field on any post, the whole frontmatter block would get reformatted. That’s the opposite of “diffs stay minimal,” and it’s the issue’s own stated success criterion contradicting its own suggested implementation.
What I built instead
Ruby’s YAML library (Psych) has a lower-level API most people never touch: Psych.parse_stream returns a full node tree (Psych::Nodes::Scalar, Sequence, Mapping) instead of a plain Hash. Each scalar node remembers its own quoting style. If I only mutate the node for the field I’m actually changing and leave every other node alone, re-emitting the tree preserves the original formatting for everything I didn’t touch.
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)
The line_width: -1 took me a minute to find. Without it, Psych’s emitter wraps long scalars (titles, descriptions) at ~80 columns by default, which reformats every post with a long title even with zero fields edited. That one option was the difference between “looks like it works” and actually passing a real round-trip test.
Validating it before writing a line of production code
Before committing to this design I wrote a throwaway script and ran it against all 166 live posts: parse, re-emit, diff against the original. 155 of 166 came back byte-identical with zero edits. The other 11 differed by exactly one thing: a handful of older posts wrote tags: on its own line with the array on a continuation line (sometimes one big multi-line block with a trailing comma). My serializer collapses that onto one line. Still valid YAML, semantically identical, and stable under a second round trip - I checked that serialize(serialize(x)) == serialize(x) for all 11, so it’s a one-time settle, not drift. Zero broken.
That gave me the confidence to build the real thing instead of a Hash-based version I’d have had to walk back later.
Design decisions
I ended up with a three-way classification of every frontmatter field, not just “known vs. unknown”: editable fields this panel writes (title, description, pubDate, category, author, tags, affiliates); known pass-through fields that are preserved but whose position matters for inserting new keys correctly (lane, heroImage, heroImageCredit); and unknown fields, preserved verbatim, always after the known ones.
The middle category exists because of lane - it’s required by the Astro zod schema but this editor never touches it, and heroImage/heroImageCredit are owned by a separate hero-image panel entirely. If I’d only tracked “known editable fields,” inserting a new key (say, a post that never had affiliates before) would’ve landed it in the wrong slot relative to fields I’m not even allowed to write.
I also decided not to add a new column to store the raw frontmatter text on the draft model. The editable-field validation and the round-trip serializer are useful standalone (and that’s what the CI fixture sweep tests), but the actual byte-preserving rewrite only matters at commit time, when the code needs the live file’s current content anyway to avoid clobbering someone else’s edit. That’s a separate issue’s problem, not this one’s.
What surprised me
Small YAML footgun: Psych.parse(yaml_text) returns a Mapping node directly, but you can’t call .to_yaml on a bare mapping - the emitter wants a full document/stream wrapper or it throws expected STREAM-START. Psych.parse_stream gives you the whole tree instead and just works. Took a few failed experiments to land on that.
The other surprise was a pleasant one: when I mutate an existing field’s value and leave its style attribute alone, Psych’s emitter is smart enough to fall back to quoting automatically if the new value wouldn’t be safe as plain YAML. I tested this by editing a category to "Security & Privacy: A Talk" (colon-space is special in YAML) and it just worked, re-quoted automatically, parsed back to the exact string I set. I didn’t have to write any of that escaping logic myself.
What’s next
The serializer and the metadata panel are done and tested (fixture sweep + targeted unit tests + a real browser pass through the autosave flow, including the invalid-metadata rejection path). The next piece is the commit flow - taking a draft’s edits and actually writing them to the live file on GitHub, which is where this frontmatter service earns its keep for real.
Related reading
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.
The editor commit button is a deploy button
Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.