Turbo Frames, a defense-in-depth sanitizer, and teaching Brakeman about it
The next piece of blog-manager’s post editor was a server-rendered GFM preview panel - render the draft’s Markdown body with Commonmarker, show it in a Turbo Frame, keep it in sync with autosave. Small-sounding feature, but it touched four things I hadn’t done in this app before: the first Turbo Frame, a hand-written HTML sanitizer allowlist, a Node-free approach to prose typography, and teaching Brakeman about a false positive instead of just shrugging at a red CI run.
The toggle instead of side-by-side
The issue said “toggle or side-by-side.” The editor’s layout is a wide body column plus a narrow 320px sidebar (where Metadata/Tags/Hero/Translation already live). A rendered article in 320px would be unreadable, so I went with a Write/Preview toggle inside the main column - two buttons that swap the textarea for a Turbo Frame in the same space. Simple aria-pressed toggle buttons rather than a full ARIA tabs pattern, since it’s a binary switch, not a multi-tab widget.
The first Turbo Frame in this app
Nothing in blog-manager used a Turbo Frame before this. The gotcha I didn’t expect: when a frame reloads, Turbo replaces the whole <turbo-frame> element - including its attributes - with whatever comes back from the response. I’d put a Stimulus target attribute (data-draft-target="previewFrame") only on the frame’s initial placeholder markup in the editor page. First reload, and that attribute would’ve vanished, silently breaking every future this.previewFrameTarget lookup. Fix was mechanical once I understood it: the controller’s response partial needs to carry the exact same data-draft-target attribute on its own turbo_frame_tag call, not just the page that first renders the frame.
I also skipped Turbo’s native loading="lazy" attribute on purpose. It’s IntersectionObserver-based, and an element with display:none (which is how I hide the preview pane while writing) never intersects - so lazy-loading would just never fire. I drive the frame’s src entirely from Stimulus instead: no src until the user clicks Preview for the first time, then frame.reload() on every later toggle or autosave.
Sanitization: defense in depth, not just one gate
Commonmarker (Rust/comrak-backed) strips raw embedded HTML from the Markdown source by default - I confirmed this before trusting it, by feeding it a literal <script> tag and checking the intermediate output: it comes out as an HTML comment placeholder, never reaches the DOM. But I didn’t want the preview’s safety to depend on one library’s internal behavior never changing, so I ran the output through Rails’ own Rails::Html::SafeListSanitizer too, with an explicit tag/attribute allowlist scoped to exactly what GFM can produce (headings, lists, tables, code, tasklist checkboxes - no iframe, no style, no on* attributes). Two independent layers that don’t share an assumption. Wrote tests asserting a script tag and an onerror attribute both get stripped, not just that the happy path renders.
Teaching Brakeman instead of arguing with it
Marking the sanitized HTML .html_safe in the view (the standard, correct way to render pre-sanitized content in Rails) trips Brakeman’s generic “unescaped model attribute” check - it can’t see through a custom service object to know the string was actually sanitized. Rather than downgrading the check or reflexively silencing something CI depends on, I used Brakeman’s own ignore-file mechanism: generated the exact warning fingerprint, attached a note explaining the two-layer sanitization and pointing at the test file that proves it, and committed config/brakeman.ignore. bin/brakeman couldn’t run interactively in my environment (no real TTY for the prompt), so I drove Brakeman’s own Ruby API directly - scanned, found the warning by fingerprint, called .to_hash on it, wrote the note, saved the file in Brakeman’s exact expected JSON shape. Same result as running brakeman -I by hand, just scripted.
Prose typography without Node
This repo has zero Node/npm footprint - Tailwind v4 runs entirely through the gem, no package.json anywhere. Adding @tailwindcss/typography for prose-style classes would have meant introducing an entire JS toolchain for one CSS plugin. I hand-rolled a .prose block instead, matching the app’s existing hand-authored component-CSS style (same pattern as .field-input, .btn) and referencing the same design-token CSS variables everything else uses - which means dark mode support came for free, no separate dark-mode override block needed.
Numbers
Benchmarked against a real 22.6KB vdaluz.com post (20 runs): average 9.2ms, worst case 30.6ms. The issue’s target was under 500ms. Commonmarker being Rust-backed made this a non-question - the budget was never going to be the bottleneck.
What’s next
Preview panel is done and tested (renderer unit tests, controller tests, full browser pass including the autosave-triggered refresh working even when metadata validation fails but the body still saves - an interaction with the metadata panel’s fix from earlier the same day). Next up in the editor cluster: the hero panel and the commit flow, both still blocked behind this work until now.
Related reading
Turning a Pexels-only hero picker into a provider registry (and the review that caught a license forgery)
A boring refactor with an un-boring finding: an unvalidated provider param that could have committed a mislicensed image to the live blog as legitimately self-hostable.
Three lines of config, an afternoon of verification
Uncommenting Rails SSL flags took ten minutes. Reading the framework source, testing two plausible-but-wrong review findings, and proving the live cookie took the rest.
Fixing a redirect, and the edge case one line missed
A one-word fix to the login return-path that a scanner and a review each caught being incomplete: HEAD requests slipping past, and a stale stored URL nobody cleared.