Skip to content

Instantly share code, notes, and snippets.

@henrik
Last active July 9, 2025 15:19
Show Gist options
  • Save henrik/f6619f298de3b6f59dfba589fa5476d7 to your computer and use it in GitHub Desktop.
Save henrik/f6619f298de3b6f59dfba589fa5476d7 to your computer and use it in GitHub Desktop.
`t_html_safely` for Ruby/Rails i18n that respects the HTML safety of interpolated values.

Ruby/Rails i18n does not (per 2025-07-07) respect the HTML-safety of interpolated values, so you need to do this:

I18n.t("foo.bar", link: link_to("Help", help_path)).html_safe

Even though link_to returns a html_safe? string, it will be escaped unless we flag the entire string as safe.

Flagging the whole string is at best inelegant, but also opens up for XSS injection attacks.

Less likely, a translator will inject evil HTML into the translation strings themselves. (But they should not be able to, however unlikely.)

More likely, you interpolate multiple values and accidentally trust all of them:

I18n.t("foo.bar", customer: customer.name, link: link_to("Help", help_path)).html_safe

This Gist has code for a t_html_safely method that behaves in a safer way.

Example usage

In a view:

t_html_safely(".foo", link: link_to("Help", help_path))

Outside of a view:

I18nHelper.t_html_safely("foo", link: a_html_safe_string)
module I18nHelper
class OutputSafety
extend ActionView::Helpers::OutputSafetyHelper
end
PLACEHOLDER_REGEX_FOR_SPLIT = /(%\{\w+\})/
PLACEHOLDER_REGEX_FOR_MATCH = /\A%\{(\w+)\}\z/
# Make this usable without having to include it, e.g. `I18nHelper.t_html_safely`.
extend self
# Safer translations where HTML-safe interpolations are respected, without having to mark translator-provided text as safe.
def t_html_safely(key, scope: nil, default: :_no_default, **interpolations)
maybe_default = default == :_no_default ? {} : { default: }
# Get a literal string like `"Hello %{name}!`"
# We could get it by not passing any interpolations, but then we wouldn't get `I18n::MissingInterpolationArgument` checks.
with_placeholders = t(key, scope:, **maybe_default, **interpolations.to_h { |k, v| [ k, "%{#{k}}" ] })
# Split into parts like `[ "Hello ", "%{name}", "!" ]`.
parts = with_placeholders.split(PLACEHOLDER_REGEX_FOR_SPLIT)
OutputSafety.safe_join(parts.map {
if it.match(PLACEHOLDER_REGEX_FOR_MATCH)
interpolations.fetch($1.to_sym)
else
it
end
})
end
# If this is mixed into a context with its own `t` (i.e. the Rails view context), use that. Otherwise, use I18n.t.
def t(...)
defined?(super) ? super : I18n.t(...)
end
end
require "rails_helper"
RSpec.describe I18nHelper do
describe ".t_html_safely" do
before(:each) do
I18n.backend.reload!
end
after(:each) do
I18n.backend.reload!
end
it "returns a HTML-safe string, respecting HTML-safe interpolations but escaping anything else" do
I18n.backend.store_translations(I18n.locale, {
foo: "<b>Hello</b> %{adjective} %{world}!",
})
actual = I18nHelper.t_html_safely(:foo, adjective: "<u>dear</u>", world: "<i>world</i>".html_safe)
expect(actual).to eq("&lt;b&gt;Hello&lt;/b&gt; &lt;u&gt;dear&lt;/u&gt; <i>world</i>!")
expect(actual).to be_html_safe
end
it "does not treat _html keys any different" do
I18n.backend.store_translations(I18n.locale, {
foo_html: "<b>Hello</b> %{world}!",
})
actual = I18nHelper.t_html_safely(:foo_html, world: "<i>world</i>".html_safe)
expect(actual).to eq("&lt;b&gt;Hello&lt;/b&gt; <i>world</i>!")
expect(actual).to be_html_safe
end
context "handling the 'default' argument" do
it "uses it if given" do
I18n.backend.store_translations(I18n.locale, {
bar: "<b>Goodbye</b> %{world}!",
})
actual = I18nHelper.t_html_safely(:foo, world: "<i>world</i>".html_safe, default: [ :bar, "Not here" ])
expect(actual).to eq("&lt;b&gt;Goodbye&lt;/b&gt; <i>world</i>!")
expect(actual).to be_html_safe
end
it "does not add a default if not given" do
I18n.backend.store_translations(I18n.locale, {
bar: "<b>Goodbye</b> %{world}!",
})
expect {
I18nHelper.t_html_safely(:foo, world: "<i>world</i>".html_safe)
}.to raise_error(I18n::MissingTranslationData)
end
end
it "raises for missing interpolation arguments (e.g. string keys instead of symbols)" do
I18n.backend.store_translations(I18n.locale, {
foo: "<b>Hello</b> %{world}!",
})
expect {
I18nHelper.t_html_safely(:foo, "world" => "<i>world</i>".html_safe)
}.to raise_error(I18n::MissingInterpolationArgument)
end
it "calls the 't' from the current context when present" do
klass = Class.new {
def t(...) = "¡<b>Hola</b> %{world}!"
def run
t_html_safely(:whatever, world: "<i>mundo</i>".html_safe)
end
}
klass.include described_class
actual = klass.new.run
expect(actual).to eq("¡&lt;b&gt;Hola&lt;/b&gt; <i>mundo</i>!")
expect(actual).to be_html_safe
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment