Responsive code formatting on web
Responsive Web Design was a hot topic over a decade ago. I'm old enough to remember Nicolas Barrera delivering a talk about it on wroclove.rb. It now feels strange not to design interfaces mobile-first, which is the primary screen for consuming content. And yet, when browsing code samples published online, more often than not we're left with with horizontal scrolls. Or worse, the hidden overflows.
Perhaps programmers prefer consuming programming content on bigger screens. That's me. I read posts with code samples frequently. I'm somewhat active blogger too. Preparing code snippets for publication takes some effort, even for bigger screens. Whether its breaking long lines to make syntactic sense manually or an automated run of code formatter, it is additional work.
Speaking of code formatters, it's unusual nowadays to encounter a project without one. They're convenient tools that parse source code into Abstract Syntax Tree, only to spit it out as text representation once again within a few guiding rules. The main rule being "the width".
One width to rule them all (code editors, Github code diffs).
What if we could make formatters to be a little more helpful, responding to changing screen size? Like if you're on mobile, assume there's only 40 columns of screen available. Split screen on a laptop? Make it 80, for the Vim people who can quit. Everything else — a generous 120 columns. We're still talking Ruby.
Code snippet at 40 columns:
module Processes
class ReservationProcess
include Infra::ProcessManager.with_state {
ProcessState
}
subscribes_to(
Pricing::OfferAccepted,
Fulfillment::OrderCancelled,
Fulfillment::OrderConfirmed
)
end
end
Code snippet at 80 columns:
module Processes
class ReservationProcess
include Infra::ProcessManager.with_state { ProcessState }
subscribes_to(
Pricing::OfferAccepted,
Fulfillment::OrderCancelled,
Fulfillment::OrderConfirmed
)
end
end
Code snipper at 120 columns:
module Processes
class ReservationProcess
include Infra::ProcessManager.with_state { ProcessState }
subscribes_to(Pricing::OfferAccepted, Fulfillment::OrderCancelled, Fulfillment::OrderConfirmed)
end
end
WebAssembly
Last year I've attended a DRUG meetup where Filip Pacanowski shared his experiments with WebAssembly.
WebAssembly (abbreviated Wasm) is a binary instruction format for a stack-based virtual machine. Wasm is designed as a portable compilation target for programming languages, enabling deployment on the web for client and server applications.
Your web browser has this WASM VM built-in. You could run it standalone on your machine. You can target VMs running on edge computing platforms. It is sandboxed, with quite limited interactions with the interfaces.
That short presentation sparked my interest: what if I could use WASM to format Ruby code, using familiar Ruby code formatter, in the browser? Formatting on the client-side means no work preparing the code for publication earlier. Just a javascript drop-in to progressively enhance the content. Plus I was eager to experiment with WASM myself. Good news — someone already ported Ruby VM to WASM.
Packaging Ruby VM for WASM with additional dependencies is pretty straightforward with rbwasm gem:
# Gemfile.wasm
source 'https://rubygems.org'
gem 'js'
gem 'rouge'
gem 'ruby_wasm'
gem 'syntax_tree'
BUNDLE_GEMFILE=Gemfile.wasm bundle exec rbwasm build \
-o ruby.wasm -build-profile minimal --ruby-version 3.4
Once you have the output binary, here's how you run it in the browser:
<script type="module">
import { DefaultRubyVM } from "https://cdn.jsdelivr.net/npm/@ruby/wasm-wasi@2.7.1/dist/browser/+esm";
const response = await fetch("ruby.wasm");
const module = await WebAssembly.compileStreaming(response);
const { vm } = await DefaultRubyVM(module);
const initialSource = document.querySelector("textarea[hidden]")
const code = document.querySelector("code")
const format = (breakpoint) => {
code.innerHTML =
vm.eval(`
require "js"
require "syntax_tree"
require "rouge"
max_columns_width =
case "${breakpoint}"
when "sm" then 40
when "md" then 80
else 120
end
Rouge::Formatters::HTML.new.format(
Rouge::Lexers::Ruby.new.lex(
SyntaxTree.format(<<~SRC, max_columns_width)
${initialSource.value}
SRC
)
)
`).toString();
};
</script>
Here's the WASM demo. It reads the lengthy source code from a hidden textarea. Detects screen size changes. When new breakpoint is reached, it outputs formatted and colorized code to the designated container. Width in columns corresponds to the breakpoint. Try resizing the screen and see what happens.
The breakpoint detector:
<script>
const detectBreakepointChange = (callback) => {
const widthToBreakpoint = (width) => {
if (width < 600) { return 'sm'; }
else if (width < 1000) { return 'md'; }
else { return 'lg'; };
};
let currentBreakpoint = widthToBreakpoint(window.innerWidth);
window.addEventListener("resize", () => {
let breakpoint = widthToBreakpoint(window.innerWidth);
if (breakpoint != currentBreakpoint) {
currentBreakpoint = breakpoint;
callback(breakpoint);
};
});
};
detectBreakepointChange(format);
</script>
Was the demo smooth? Did you have to wait a bit to get it running? The main problem with client-side formatter running in Ruby VM ported to WebAssembly is the size. Who wants to download 50 MB to read an article? As cool as it initially sounded, this isn't a viable option to adopt.
Hidden DOM elements
When client-side fails, backend to the rescue. Instead of shipping a hefty binary to the browser, let's pre-render the desired breakpoints. Code sample exists as 3 distinct DOM elements and we cycle through them according to current size. The current one is visible, the rest is hidden. We can also rely on CSS media queries, no need for fancy JS breakpoint detector.
The plumbing for nanoc, a static site generator of choice:
hidden_dom_renderer =
Class.new(Redcarpet::Render::HTML) do
include Rouge::Plugins::Redcarpet
prepend(Module.new {
def block_code(code, language)
@options.fetch(:widths).map { |width| <<~EOS }.join
<div data-width="#{width}">
#{super(SyntaxTree.format(code, width), language)}
</div>
EOS
end
})
end
breakpoints = { sm: 40, md: 80, lg: 120 }
compile "/**/hidden_dom.md" do
filter :erb
filter :redcarpet,
options: {
fenced_code_blocks: true
},
renderer: hidden_dom_renderer,
renderer_options: {
widths: breakpoints.values
}
layout "/hidden_dom.*"
write item.identifier.without_ext + "/index.html"
end
The media queries:
<style>
[data-width] { display: none; }
[data-width="120"] { display: block; }
@media (max-width: 999px) {
[data-width] { display: none; }
[data-width="80"] { display: block; }
}
@media (max-width: 599px) {
[data-width] { display: none; }
[data-width="40"] { display: block; }
}
</style>
The hidden DOM demo.
It works. It's simple. And it's boring. Perfect score, would use again.
Is it universally good though or are there some edge cases where it could be problematic? Additional DOM elements for a few code snippets are no harm. Would it be a problem if this was a code diff screen, representing lots of changed files? I can vividly remember Github PR page barely loading on larger pull requests. Would it be smoother with 3 times more DOM elements?
Hotwire and Turbo Frames
I wondered if there's a third code formatting option, that plays well on DOM-heavy pages. If we don't want additional DOM elements on a single document, then let's have multiple documents, each pre-rendered on a backend. When screen size changes and breakpoint activates, we fetch the document that matches current breakpoint and replace code snippets in the DOM tree.
It sounds like a lot of work, but it isn't with Hotwire. There's a Turbo Frames mechanism, that allows predefined parts of a page to be updated on request. If we wrap each code snippet in a <turbo-frame> with unique ID, these snippets will be replaced with matching ones coming from a requested page from the server. Frames can also have src attribute. Its value specifies URL to be loaded when <turbo-frame> tag appears on the page. By manipulating src, we're initiating requests.
Here it is, the final boss. Turbo demo.
Putting it all together, with familiar breakpoint detector:
<script type="module" src="https://cdn.jsdelivr.net/npm/@hotwired/turbo@latest/dist/turbo.es2017-esm.min.js"></script>
<script>
const format = (breakpoint) =>
document
.querySelectorAll("turbo-frame")
.forEach((el) => { el.src = `/turbo/${breakpoint}/` });
const detectBreakepointChange = (callback) => {
const widthToBreakpoint = (width) => {
if (width < 600) { return 'sm'; }
else if (width < 1000) { return 'md'; }
else { return 'lg'; };
};
let currentBreakpoint = widthToBreakpoint(window.innerWidth);
window.addEventListener("resize", () => {
let breakpoint = widthToBreakpoint(window.innerWidth);
if (breakpoint != currentBreakpoint) {
currentBreakpoint = breakpoint;
callback(breakpoint);
};
});
};
detectBreakepointChange(format);
</script>
The plumbing for static site generator to generate multiple pages from one source:
turbo_renderer =
Class.new(Redcarpet::Render::HTML) do
include Rouge::Plugins::Redcarpet
prepend(Module.new {
def block_code(code, language)
digest = Digest::SHA1.hexdigest(code)
formatted_code = SyntaxTree.format(code, @options.fetch(:width))
<<~EOS
<turbo-frame id="#{digest}">
#{super(formatted_code, language)}
</turbo-frame>
EOS
end
})
end
compile "/**/turbo.md" do
filter :erb
filter :redcarpet,
options: {
fenced_code_blocks: true
},
renderer: turbo_renderer,
renderer_options: {
width: breakpoints.values.last
}
layout "/turbo.*"
write item.identifier.without_ext + "/index.html"
end
breakpoints.each do |name, columns|
compile "/**/turbo.md", rep: name do
filter :erb
filter :redcarpet,
options: {
fenced_code_blocks: true
},
renderer: turbo_renderer,
renderer_options: {
width: columns
}
layout "/turbo.*"
write item.identifier.without_ext + "/#{name}/index.html"
end
end
I know what you're thinking now: each code snippet generates a request to the server. How is this better?
First of all I've used src to make requests out of laziness. It worked good enough for a demo. You can initiate HTTP request for a page with differently sized snippets in other ways. Secondly, the documents cache well in browsers storage. Subsequent GET requests initiated from <trubo-frame> elements to the same resource wouldn't even reach the network.
It requires a JS dependency and a breakpoint detection mechanism. In return — no extra DOM elements. Ideal when this matters the most.
And the winner is...
In the end each of the proposed solutions has its strong points that make it more suitable to particular problem than others. I'm comfortable with pre-rendering on the backend and not looking for a universal drop-in. I'll be sticking with the hidden DOM elements for its lack of any external dependencies and being delightfully boring.