Module: ReactOnRails::FontHelper

Extended by:
ActiveSupport::Concern
Included in:
Helper
Defined in:
lib/react_on_rails/font_helper.rb

Overview

First-party font optimization helper, a ‘next/font/local` analog for Rails.

Given a self-hosted (committed + fingerprinted) ‘.woff2` file already served by your asset pipeline, this returns the markup an ERB view should place in the document `<head>`:

1. `<link rel="preload" as="font" type="font/woff2" crossorigin>` so the
   browser fetches the font in parallel with the first paint;
2. an `@font-face` rule with `font-display: swap` so text renders immediately
   with a fallback and swaps in the web font when ready;
3. an optional metric-matched fallback `@font-face` (`size-adjust` plus
   `ascent-override` / `descent-override` / `line-gap-override`) so the system
   fallback occupies the same space as the web font, eliminating the layout
   shift (CLS) that normally happens on the swap.

It mirrors the ‘<head>`-injection convention used by `react_component_hash` (see `ReactOnRails::Helper`): the caller wraps the return value in `content_for :head`, and the layout yields it inside `<head>`.

Example (ERB view):

<% content_for :head do %>
  <%= react_on_rails_font_face(
        family: "Inter",
        src: asset_path("inter-latin-400-normal.woff2"),
        weight: 400,
        fallback: {
          family: "Arial",
          size_adjust: "107.12%",
          ascent_override: "90.44%",
          descent_override: "22.52%",
          line_gap_override: "0.0%"
        }
      ) %>
<% end %>

Then set the CSS font stack to the web font followed by its fallback face, e.g. ‘font-family: “Inter”, “Inter Fallback”, sans-serif;`.

Constant Summary collapse

UNSAFE_TOKEN =

CSS/HTML metacharacters that would let an argument break out of the ‘<style>` / `<link>` context this helper emits.

/[<>"\r\n]/

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.ensure_safe!(name, value) ⇒ Object

Rejects values that could break out of the CSS/HTML context. Font helper arguments are emitted into trusted ‘<head>` markup, so they must be developer-controlled, not end-user input.

Raises:

  • (ArgumentError)


111
112
113
114
115
116
117
# File 'lib/react_on_rails/font_helper.rb', line 111

def self.ensure_safe!(name, value)
  return unless value.to_s.match?(UNSAFE_TOKEN)

  raise ArgumentError,
        "react_on_rails_font_face: #{name}=#{value.inspect} contains an unsafe character " \
        "(<, >, \", or newline); font arguments must be developer-controlled, not end-user input."
end

.fallback_font_face_rule(family, fallback, weight:, style:) ⇒ Object

Generates the metric-matched fallback face. Hardcode ‘size-adjust` and the override percentages from the font’s published metrics (see the fonts docs for how the Inter-over-Arial numbers are derived). The ‘font-weight` and `font-style` mirror the primary face so the browser matches this fallback to the same elements (otherwise the size-adjust CLS protection silently fails for non-400 weights / non-normal styles), the way `next/font` generates its weight-matched fallback.



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
# File 'lib/react_on_rails/font_helper.rb', line 144

def self.fallback_font_face_rule(family, fallback, weight:, style:)
  name = fallback[:name] || "#{family} Fallback"
  local = fallback.fetch(:family)
  lines = [
    "@font-face {",
    %(  font-family: "#{name}";),
    %(  src: local("#{local}");),
    "  font-weight: #{weight};",
    "  font-style: #{style};"
  ]
  lines << "  size-adjust: #{fallback[:size_adjust]};" if fallback[:size_adjust]
  lines << "  ascent-override: #{fallback[:ascent_override]};" if fallback[:ascent_override]
  lines << "  descent-override: #{fallback[:descent_override]};" if fallback[:descent_override]
  lines << "  line-gap-override: #{fallback[:line_gap_override]};" if fallback[:line_gap_override]
  lines << "}"
  lines.join("\n")
end

.font_face_markup(family:, src:, weight: 400, style: "normal", display: "swap", unicode_range: nil, preload: true, fallback: nil) ⇒ Object

Pure-string builder so the markup can be unit-tested without a view context. Returns a plain (not html_safe) String.



82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
# File 'lib/react_on_rails/font_helper.rb', line 82

def self.font_face_markup(family:, src:, weight: 400, style: "normal", display: "swap",
                          unicode_range: nil, preload: true, fallback: nil)
  # Validate every value interpolated into the trusted <style>/<link> markup so no
  # argument can break out of the CSS/HTML context (see the contract in the docstring).
  ensure_safe!("family", family)
  ensure_safe!("src", src)
  ensure_safe!("weight", weight)
  ensure_safe!("style", style)
  ensure_safe!("display", display)
  ensure_safe!("unicode_range", unicode_range) if unicode_range
  if fallback
    ensure_safe!("fallback[:family]", fallback.fetch(:family))
    ensure_safe!("fallback[:name]", fallback[:name]) if fallback[:name]
    %i[size_adjust ascent_override descent_override line_gap_override].each do |key|
      ensure_safe!("fallback[:#{key}]", fallback[key]) if fallback[key]
    end
  end
  rule = font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
  parts = []
  parts << preload_link(src) if preload
  parts << +"<style>\n#{rule}"
  parts[-1] << "\n#{fallback_font_face_rule(family, fallback, weight:, style:)}" if fallback
  parts[-1] << "\n</style>"
  parts.join("\n")
end

.font_face_rule(family:, src:, weight:, style:, display:, unicode_range:) ⇒ Object



123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/react_on_rails/font_helper.rb', line 123

def self.font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
  lines = [
    "@font-face {",
    %(  font-family: "#{family}";),
    %(  src: url("#{src}") format("woff2");),
    "  font-weight: #{weight};",
    "  font-style: #{style};",
    "  font-display: #{display};"
  ]
  lines << "  unicode-range: #{unicode_range};" if unicode_range
  lines << "}"
  lines.join("\n")
end


119
120
121
# File 'lib/react_on_rails/font_helper.rb', line 119

def self.preload_link(src)
  %(<link rel="preload" href="#{src}" as="font" type="font/woff2" crossorigin="anonymous">)
end

Instance Method Details

#react_on_rails_font_face(family:, src:, weight: 400, style: "normal", display: "swap", unicode_range: nil, preload: true, fallback: nil) ⇒ ActiveSupport::SafeBuffer

Note:

SECURITY: arguments are interpolated verbatim into the trusted CSS/HTML emitted into ‘<head>` and the result is marked `html_safe`. Pass only developer-controlled values (font names, asset paths), never end-user input. Values containing `<`, `>`, `“`, or a newline raise `ArgumentError`.

Emits the preload ‘<link>`, the primary `@font-face`, and (when `fallback:` is supplied) a metric-matched fallback `@font-face`.

Parameters:

  • family (String)

    CSS font-family name for the web font (e.g. “Inter”).

  • src (String)

    URL/path to the ‘.woff2` (typically `asset_path(…)`).

  • weight (Integer, String) (defaults to: 400)

    ‘font-weight` (default 400). A range like “100 900” is valid for variable fonts.

  • style (String) (defaults to: "normal")

    ‘font-style` (default “normal”).

  • display (String) (defaults to: "swap")

    ‘font-display` (default “swap”).

  • unicode_range (String, nil) (defaults to: nil)

    optional ‘unicode-range` to subset the face.

  • preload (Boolean) (defaults to: true)

    emit the preload ‘<link>` (default true).

  • fallback (Hash, nil) (defaults to: nil)

    metric-matched fallback face. Keys: :family (required, the local system font, e.g. “Arial”), :name (the generated face name, default “#family Fallback”), :size_adjust, :ascent_override, :descent_override, :line_gap_override.

Returns:

  • (ActiveSupport::SafeBuffer)

    head markup, ready for ‘content_for :head`.



72
73
74
75
76
77
78
# File 'lib/react_on_rails/font_helper.rb', line 72

def react_on_rails_font_face(family:, src:, weight: 400, style: "normal", display: "swap",
                             unicode_range: nil, preload: true, fallback: nil)
  ReactOnRails::FontHelper.font_face_markup(
    family:, src:, weight:, style:, display:,
    unicode_range:, preload:, fallback:
  ).html_safe
end