Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,14 @@ jobs:
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true
# spec:standalone compiles first: on JRuby that builds the Java escaper
# jar (ext/java), on TruffleRuby extconf.rb no-ops (pure Ruby is faster).
- name: RSpec (spec/gem without Rails)
run: bundle exec rake spec:standalone
# On JRuby this builds and renders from the -java gem (jar inside); on
# TruffleRuby the pure-Ruby gem.
- name: Packaged gem installs and renders
run: ruby bin/gem-smoke
run: bundle exec ruby bin/gem-smoke

rails-versions:
name: Matrix / Rails versions
Expand Down
37 changes: 28 additions & 9 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,21 +78,33 @@ jobs:
run: bundle exec rake smoke

jruby:
name: Verify install on JRuby
name: Verify + build the -java gem on JRuby
needs: version
if: needs.version.outputs.due == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
env:
BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/standalone.gemfile
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: jruby
bundler: none
# Builds the gem with JRuby, installs it (extconf.rb must not fail there)
# and renders with the pure-Ruby implementation.
- name: Build + install + render
run: ruby bin/gem-smoke
bundler-cache: true
# Compile the Java escaper, then build + install + render the -java gem
# (gem-smoke builds a ruby2html-<v>-java.gem and renders from it).
- name: Compile Java escaper
run: bundle exec rake compile
- name: Build + install + render the -java gem
run: bundle exec ruby bin/gem-smoke
- name: Build the -java gem for publishing
run: gem build ruby2html.gemspec
- uses: actions/upload-artifact@v7
with:
name: java-gem
path: ruby2html-*-java.gem
retention-days: 7
if-no-files-found: error

release:
name: Publish to RubyGems, tag, GitHub release
Expand All @@ -112,15 +124,22 @@ jobs:
with:
ruby-version: .ruby-version

- name: Download the JRuby-built -java gem
uses: actions/download-artifact@v7
with:
name: java-gem

- name: Exchange the OIDC token for RubyGems credentials
uses: rubygems/configure-rubygems-credentials@v2.1.0

# Gem first: a tag and release with no gem behind them would advertise a
# version `gem install` cannot serve.
- name: Build + publish gem
# version `gem install` cannot serve. Two artifacts: the C-extension gem
# (built here on CRuby) and the prebuilt -java gem (from the jruby job).
- name: Build + publish both gems
run: |
gem build ruby2html.gemspec
gem push ruby2html-*.gem
gem push ruby2html-*-java.gem
gem push "$(ls ruby2html-*.gem | grep -v -- -java)"

- name: Tag + GitHub release from CHANGELOG
env:
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
/ext/ruby2html/extconf.h
*.dSYM/
compile_commands.json

# JRuby Java escaper build artifacts (rake compile on JRuby; the -java gem
# ships this jar, but it is rebuilt from ext/java and never committed)
*.jar
*.class
.cache/clangd/

# Editors / IDEs
Expand Down
2 changes: 1 addition & 1 deletion .ruby-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.4.7
4.0.9
25 changes: 14 additions & 11 deletions AGENTS.md

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,18 @@ Entries before 1.6.6 were reconstructed from `git log` and RubyGems release date

## [Unreleased]

## [2.1.0] - 2026-10-31

### Added

- JRuby now ships a small Java extension (`Ruby2html::JavaNative`) that accelerates HTML escaping — the renderer's hot path — over the pure-Ruby implementation. About 1.9x faster end-to-end rendering on JRuby; output is byte-identical. Distributed as a `ruby2html-<version>-java` platform gem with the jar prebuilt (no JDK needed to install); everything else stays pure Ruby.

### Changed

- `Ruby2html::Component#render_in` accepts Rails' keyword options (`locals:`), as Rails main (8.2) passes them and deprecates `render_in` without options.
- TruffleRuby now uses the pure-Ruby implementation instead of the C extension. TruffleRuby runs C extensions through Sulong but JITs pure Ruby with Graal, which here renders ~28x faster; `Ruby2html.native?` is now `false` on TruffleRuby.
- On CRuby, a C extension that fails to load now prints one warning with the reason (`ruby2html: C extension not loaded (...); using the pure-Ruby renderer`) instead of silently rendering about 3x slower. JRuby/TruffleRuby and `RUBY2HTML_DISABLE_EXT=1` stay silent.
- Development and single-version CI jobs now run on the latest stable Ruby (4.0). The CRuby test matrix already tracks the official maintained-versions list automatically (currently 3.3, 3.4, 4.0).

## [2.0.0] - 2026-10-31

Expand Down Expand Up @@ -175,7 +183,8 @@ Published to RubyGems; no separate commits in the repository history.
### Added
- Initial release: `Ruby2html::Render` DSL, `Ruby2html::RailsHelper#html` for ERB.

[Unreleased]: https://github.com/sebyx07/ruby2html/compare/v2.0.0...HEAD
[Unreleased]: https://github.com/sebyx07/ruby2html/compare/v2.1.0...HEAD
[2.1.0]: https://github.com/sebyx07/ruby2html/compare/v2.0.0...v2.1.0
[2.0.0]: https://github.com/sebyx07/ruby2html/releases/tag/v2.0.0
[1.6.6]: https://rubygems.org/gems/ruby2html/versions/1.6.6
[1.6.4]: https://rubygems.org/gems/ruby2html/versions/1.6.4
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ CHECKSUMS
zeitwerk (2.8.3) sha256=2c85125a8467ce069e20123d1e709a08955c9d29c118c25b46b7b7fafdbb92e5

RUBY VERSION
ruby 3.4.7
ruby 4.0.9

BUNDLED WITH
4.0.10
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ end
- **Fastest Ruby HTML renderer we've measured.** The page benchmark renders 1.35x faster than Erubi (Rails' ERB engine) and 2.4–16x faster than Phlex 2, with 40–90% fewer allocations; in a full Rails request it leads ERB, Slim and Phlex ([numbers](#performance)).
- **Light.** No runtime dependencies, a 32 KB gem, and `require 'ruby2html'` costs about 0.4 MB and 19 ms. Prism, the Rails glue and the optional beautifier load only when used, or at boot in eager-loading production apps.
- **Plain Ruby views.** `.html.rb` templates, ERB interop, ViewComponent, or the built-in `Ruby2html::Component`. Escaped by default.
- **Runs everywhere.** CRuby ≥ 3.1 with a C extension (SIMD escaping on x86_64 and arm64), and JRuby/TruffleRuby through a byte-identical pure-Ruby implementation. Rails 7.2+.
- **Runs everywhere.** CRuby ≥ 3.1 with a C extension (SIMD escaping on x86_64 and arm64), JRuby with a Java escaper over the pure-Ruby implementation, and TruffleRuby on pure Ruby (faster there than the C extension) — all byte-identical. Rails 7.2+.
- **AI-ready.** One-file reference for coding agents: [`llms.txt`](llms.txt).

## Installation

Supports all maintained CRuby versions (>= 3.1), JRuby (via the pure-Ruby implementation), TruffleRuby, and Rails 7.2+; the CI matrix is generated automatically.
Supports all maintained CRuby versions (>= 3.1; CI currently covers 3.3, 3.4 and 4.0), JRuby (pure Ruby plus a Java escaper), TruffleRuby (pure Ruby), and Rails 7.2+; the CI matrix is generated automatically, so new Ruby releases are picked up without edits.

```ruby
# Gemfile
Expand Down Expand Up @@ -208,7 +208,7 @@ This is a dated snapshot. CI has the live numbers. Measured on Ruby 3.4.7 x86_64
|---|---|---|---|
| **ruby2html 2.0 (C extension)** | **2,676** | **0.37 ms** | **419** |
| Erubi 1.x (Rails' ERB engine) | 1,988 (1.35x slower) | 0.50 ms | 713 |
| ruby2html 2.0 (pure Ruby: JRuby, no compiler) | ~820 | ~1.2 ms | 1,029 |
| ruby2html 2.0 (pure Ruby, no C extension; CRuby) | ~820 | ~1.2 ms | 1,029 |
| Phlex 2.4, enlarged attribute cache¹ | ~1,250 (≈2.4x slower) | ~0.8 ms | 831 |
| Phlex 2.4, defaults | 164 (16x slower) | 6.1 ms | 6,410 |
| ruby2html 1.6.6 | ~620 | ~1.6 ms | 3,531 |
Expand Down
25 changes: 23 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,29 @@ end
# to that path in a checkout); an installed gem gets it in lib/gem/ruby2html/.
# Strict/clang/sanitizer variants: bin/build-ext --help
if RUBY_ENGINE == 'jruby'
desc 'No-op on JRuby (the pure-Ruby implementation is used)'
task(:compile) { puts 'JRuby: skipping the C extension, using the pure-Ruby implementation' }
# JRuby can't load the C extension, but it builds a tiny Java one: the HTML
# escaper (ext/java), the renderer's hot path. Everything else stays pure
# Ruby. Output: lib/gem/ruby2html/ruby2html_jruby.jar (on the require path in
# a checkout and in the -java gem). render.rb requires 'ruby2html/ruby2html_jruby'.
begin
require 'rake/javaextensiontask'
Rake::JavaExtensionTask.new('ruby2html_jruby') do |ext|
ext.lib_dir = 'lib/gem/ruby2html'
ext.ext_dir = 'ext/java'
ext.tmp_dir = 'tmp/ext'
ext.source_version = '8'
ext.target_version = '8'
end
rescue LoadError
desc 'Compile the Java extension (requires rake-compiler)'
task(:compile) { abort 'rake-compiler is not installed: bundle install' }
end
elsif RUBY_ENGINE == 'truffleruby'
# TruffleRuby runs the C extension through Sulong but is ~28x faster on pure
# Ruby under Graal, so extconf.rb skips the build there. rake-compiler's
# ExtensionTask would still expect a .so and fail, so don't define it.
desc 'No-op on TruffleRuby (the pure-Ruby implementation is faster than the C extension)'
task(:compile) { puts 'TruffleRuby: skipping the C extension, using the pure-Ruby implementation' }
else
begin
require 'rake/extensiontask'
Expand Down
13 changes: 10 additions & 3 deletions bin/gem-smoke
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,21 @@ Dir.mktmpdir('ruby2html-smoke') do |tmp|
%w[lib/gem/ruby2html.rb lib/gem/ruby2html/render.rb ext/ruby2html/extconf.rb README.md LICENSE CHANGELOG.md].each do |f|
abort "Gem is missing #{f}" unless files.include?(f)
end
# The -java platform gem must ship the prebuilt Java escaper jar; a jar-less
# -java gem would silently drop to the slower regex escaper. Compile first.
if Gem::Package.new(gem_path).spec.platform.to_s == 'java'
jar = 'lib/gem/ruby2html/ruby2html_jruby.jar'
abort "The -java gem is missing #{jar}: run `rake compile` on JRuby before building" unless files.include?(jar)
end
puts "== File list OK (#{files.size} files)"

gem_home = File.join(tmp, 'gems')
run!(clean_env, Gem.ruby, '-S', 'gem', 'install', '--no-document', '--install-dir', gem_home, gem_path, chdir: tmp)
puts '== Installed into temporary GEM_HOME'

# Renders with the C extension (except on JRuby) and with the pure-Ruby
# implementation (RUBY2HTML_DISABLE_EXT=1); both must produce the same HTML.
# Renders with the C extension (CRuby only -- JRuby uses the Java escaper over
# pure Ruby, TruffleRuby pure Ruby) and with the pure-Ruby implementation
# (RUBY2HTML_DISABLE_EXT=1); both must produce the same HTML.
script = <<~'RUBY'
require 'ruby2html'
expect_native = ENV['EXPECT_NATIVE'] == '1'
Expand All @@ -71,7 +78,7 @@ Dir.mktmpdir('ruby2html-smoke') do |tmp|
puts "native=#{native} #{$LOADED_FEATURES.grep(/ruby2html\.(so|bundle|dll)\z/).first}"
RUBY
env = clean_env.merge('GEM_HOME' => gem_home, 'GEM_PATH' => gem_home)
native_env = env.merge('RUBY2HTML_DISABLE_EXT' => nil, 'EXPECT_NATIVE' => RUBY_ENGINE == 'jruby' ? '0' : '1')
native_env = env.merge('RUBY2HTML_DISABLE_EXT' => nil, 'EXPECT_NATIVE' => RUBY_ENGINE == 'ruby' ? '1' : '0')
pure_env = env.merge('RUBY2HTML_DISABLE_EXT' => '1', 'EXPECT_NATIVE' => '0')
native_out = run!(native_env, RbConfig.ruby, '-e', script, chdir: tmp)
pure_out = run!(pure_env, RbConfig.ruby, '-e', script, chdir: tmp)
Expand Down
95 changes: 95 additions & 0 deletions ext/java/Ruby2htmlJrubyService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// JRuby's only native code: a byte-level HTML escaper, the renderer's hottest
// primitive. Everything else (tag methods, attributes, content rules) stays in
// the pure-Ruby twin (lib/gem/ruby2html/render/pure_ruby.rb), which JRuby's JIT
// optimizes well; on TruffleRuby even the C extension loses to pure Ruby, so
// only JRuby gets this. The C extension (ext/ruby2html/html_escape.c) is the
// reference; this must stay byte-identical with it and with PureRuby.escape.
//
// require 'ruby2html/ruby2html_jruby' loads this: JRuby maps the base name
// ruby2html_jruby to the class Ruby2htmlJrubyService (CamelCase + "Service") in
// the default package, so the name and package here are load-bearing.
//
// Built by rake-compiler's Rake::JavaExtensionTask (see Rakefile) into
// lib/gem/ruby2html/ruby2html_jruby.jar and shipped in the -java platform gem.

import org.jruby.Ruby;
import org.jruby.RubyModule;
import org.jruby.RubyString;
import org.jruby.anno.JRubyMethod;
import org.jruby.runtime.ThreadContext;
import org.jruby.runtime.builtin.IRubyObject;
import org.jruby.runtime.load.BasicLibraryService;
import org.jruby.util.ByteList;

public class Ruby2htmlJrubyService implements BasicLibraryService {
// getOrCreateModule/defineModuleUnder/defineAnnotatedMethods are marked
// deprecated on JRuby 10 but remain the load-service API and still work; the
// context-taking replacements are not available on the JRuby versions in CI.
@SuppressWarnings("deprecation")
public boolean basicLoad(final Ruby runtime) {
final RubyModule javaNative =
runtime.getOrCreateModule("Ruby2html").defineModuleUnder("JavaNative");
javaNative.defineAnnotatedMethods(Escaper.class);
return true;
}

public static final class Escaper {
private static final byte AMP = '&', LT = '<', GT = '>', QUOT = '"', APOS = '\'';
private static final byte[] E_AMP = bytes("&amp;");
private static final byte[] E_LT = bytes("&lt;");
private static final byte[] E_GT = bytes("&gt;");
private static final byte[] E_QUOT = bytes("&quot;");
private static final byte[] E_APOS = bytes("&#39;");

private static byte[] bytes(final String s) {
final byte[] b = new byte[s.length()];
for (int i = 0; i < s.length(); i++) b[i] = (byte) s.charAt(i);
return b;
}

private static boolean escapable(final byte b) {
return b == AMP || b == LT || b == GT || b == QUOT || b == APOS;
}

// Ruby2html::JavaNative.escape(str) -> String
//
// str has already been run through PureRuby.utf8_compatible, so its
// encoding is UTF-8, US-ASCII or BINARY. All five escapable characters
// are ASCII, so escaping byte by byte is identical across those
// encodings and for bytes that are invalid UTF-8 (the C extension and
// PureRuby both pass such bytes through). The result keeps the input's
// encoding. A clean string is returned unchanged (same object), which
// PureRuby.escape_html relies on to avoid a copy.
@JRubyMethod(name = "escape", module = true)
public static IRubyObject escape(final ThreadContext ctx, final IRubyObject self,
final IRubyObject arg) {
final RubyString str = (RubyString) arg;
final ByteList in = str.getByteList();
final byte[] src = in.getUnsafeBytes();
final int begin = in.getBegin();
final int len = in.getRealSize();

int first = -1;
for (int i = 0; i < len; i++) {
if (escapable(src[begin + i])) { first = i; break; }
}
if (first < 0) return str; // nothing to escape: hand back the same string

final ByteList out = new ByteList(len + 16);
out.setEncoding(in.getEncoding());
out.append(src, begin, first);
for (int i = first; i < len; i++) {
final byte b = src[begin + i];
switch (b) {
case AMP: out.append(E_AMP); break;
case LT: out.append(E_LT); break;
case GT: out.append(E_GT); break;
case QUOT: out.append(E_QUOT); break;
case APOS: out.append(E_APOS); break;
default: out.append(b);
}
}
return RubyString.newString(ctx.runtime, out);
}
}
}
4 changes: 4 additions & 0 deletions ext/ruby2html/extconf.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ def skip_native_extension(reason)

skip_native_extension('RUBY2HTML_DISABLE_EXT=1') if ENV['RUBY2HTML_DISABLE_EXT'] == '1'
skip_native_extension('JRuby does not load C extensions') if RUBY_ENGINE == 'jruby'
# TruffleRuby runs C extensions through Sulong (an LLVM interpreter) but gives
# pure Ruby the full Graal JIT, which here renders ~28x faster than the
# extension. Building it would only slow TruffleRuby down, so don't.
skip_native_extension('TruffleRuby is faster on the pure-Ruby renderer') if RUBY_ENGINE == 'truffleruby'

require 'mkmf'

Expand Down
8 changes: 6 additions & 2 deletions lib/gem/ruby2html/render.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ module Ruby2html
# into lib/ruby2html.
def self.load_native_extension
return false if ENV['RUBY2HTML_DISABLE_EXT'] == '1'
# JRuby has no C extension (it uses a Java escaper over the pure-Ruby twin,
# see load_java_escaper). TruffleRuby *can* load it via Sulong, but renders
# ~28x slower that way than on pure Ruby under the Graal JIT, so never do.
return false if RUBY_ENGINE == 'jruby' || RUBY_ENGINE == 'truffleruby'

begin
require 'ruby2html/ruby2html'
Expand All @@ -17,9 +21,9 @@ def self.load_native_extension
end
defined?(Native) ? true : false
rescue LoadError => e
# Expected on JRuby/TruffleRuby. On CRuby it means a broken or missing build: say so instead of
# On CRuby a missing extension means a broken build: say so instead of
# silently rendering about 3x slower.
warn "ruby2html: C extension not loaded (#{e.message}); using the pure-Ruby renderer" if RUBY_ENGINE == 'ruby'
warn "ruby2html: C extension not loaded (#{e.message}); using the pure-Ruby renderer"
false
end
private_class_method :load_native_extension
Expand Down
17 changes: 17 additions & 0 deletions lib/gem/ruby2html/render/pure_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
nil
end

if RUBY_ENGINE == 'jruby' && ENV['RUBY2HTML_DISABLE_EXT'] != '1'
begin
# Defines Ruby2html::JavaNative (a byte-level HTML escaper, ext/java).
# JRuby's only native code; PureRuby.escape uses it when present.
require 'ruby2html/ruby2html_jruby'
rescue LoadError
nil
end
end

module Ruby2html
# Pure-Ruby twin of the C extension's Ruby2html::Native (ext/ruby2html),
# used on JRuby, when the extension isn't built, or with
Expand All @@ -33,6 +43,13 @@ class << self
def escape(str)
ERB::Escape.html_escape(utf8_compatible(str))
end
elsif defined?(Ruby2html::JavaNative)
# JRuby: the Java escaper (ext/java). Byte-identical with the regex
# branch below (and with the C extension); also returns str itself
# when clean. utf8_compatible first, so it sees UTF-8/US-ASCII/BINARY.
def escape(str)
Ruby2html::JavaNative.escape(utf8_compatible(str))
end
else
def escape(str)
str = utf8_compatible(str)
Expand Down
2 changes: 1 addition & 1 deletion lib/gem/ruby2html/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Ruby2html
VERSION = '2.0.0'
VERSION = '2.1.0'
end
Loading
Loading