intertwingly

It’s just data

Ruby2JS: Self-Hosted


The Ruby2JS documentation site now runs its live demos using a selfhost architecture: Ruby2JS transpiled by Ruby2JS itself into JavaScript. The Opal dependency that previously powered the in-browser demos has been completely removed.

We also replaced Bulma and Sass with plain CSS. The site used 22 of Bulma's classes. We wrote ~300 lines of CSS with custom properties, explicit media queries, and pre-computed color values. No preprocessor, no build step for styles — just CSS that browsers understand natively.

What Changed

The previous architecture used Opal to compile the Ruby2JS transpiler into a 5.3MB JavaScript bundle. This worked, but it was heavy, hard to update, and carried an entire Ruby runtime in the browser.

The new approach is simpler: Ruby2JS transpiles its own source code to JavaScript, producing a ~300KB bundle. Ruby parsing is handled by Prism compiled to WebAssembly (~730KB) — the same parser that Ruby itself uses. Filters load on-demand via dynamic import().

Before After
Transpiler Opal bundle (5.3MB) Selfhost + Prism WASM (~1MB)
CSS framework Bulma via Sass (245KB) Plain CSS (41KB)
Build warnings 297 Sass deprecation warnings 0
Dependencies removed bulma, sass, opal, wunderbar, base64

Surprises Along the Way

The File polyfill saga. Ruby2JS's selfhost bundle defines File.exist and File.mtime for Node.js compatibility. Our first three attempts at guarding this broke something: Node.js 20+ has a native File class, typeof window fails under jsdom, and replacing File entirely destroys the constructor that Turbo needs for instanceof checks. The fix: add the methods to the existing File object rather than replacing it.

Static import in the browser. Several transpiled filter files had import path from "node:path" at the top level. Unlike require(), ES module imports can't be conditionally guarded — the browser tries to resolve them immediately. We post-process deployed filter copies to replace these with browser-safe stubs.

CSS @import ordering. postcss-import silently drops @import statements that appear after any regular CSS rules. We had custom properties defined before the imports, and the entire stylesheet vanished — no error, no warning.

color-mix() ≠ Sass darken(). They operate in different color spaces. A note warning background went from light pink to noticeably darker salmon. We ended up computing exact hex values to match the original Sass output.

Try It Yourself

The selfhost transpiler is available for direct browser use:

import { convert, initPrism } from
  'https://www.ruby2js.com/demo/selfhost/ruby2js.js';

await initPrism();
let result = convert('puts "Hello, world!"', { eslevel: 2022 });
console.log(result.toString());
// => console.log("Hello, world!")

Filters load on-demand:

await import(
  'https://www.ruby2js.com/demo/selfhost/filters/functions.js');

let result = convert(
  '[1,2,3].select { |n| n > 1 }',
  { eslevel: 2022, filters: ['functions'] }
);
// => [1, 2, 3].filter(n => n > 1)

See the Running the Demo documentation for the full list of available filters and options.

What This Means

Ruby2JS has always been about producing JavaScript that looks hand-crafted. Having the documentation site itself be powered by that output is the strongest possible demonstration of that goal. Every interactive example on the site is Ruby2JS eating its own cooking.