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 routine provider-registry refactor turned up an unvalidated param that could commit a mislicensed image to the live blog as legitimately self-hostable.
Three lines of config, an afternoon of verification
Uncommenting three Rails SSL flags took ten minutes. Reading the framework source and testing two plausible but wrong review findings took the rest of the day.
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.
You might also find useful
Proton Pass
Privacy-focused password manager from the team behind Proton Mail.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreProton VPN
Commercial VPN with NetShield filtering and a kill switch.
As a Proton Partner, I earn from qualifying purchases of Proton's privacy and security services (Pass, Mail, VPN, Drive).
Learn moreRackNerd VPS
Budget VPS hosting for lightweight always-on services.
As a RackNerd affiliate, I earn from qualifying purchases.
Learn more