react_on_rails 17.0.0.rc.2 → 17.0.0.rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile.lock +1 -1
  3. data/lib/generators/USAGE +6 -0
  4. data/lib/generators/react_on_rails/base_generator.rb +20 -2
  5. data/lib/generators/react_on_rails/generator_helper.rb +7 -0
  6. data/lib/generators/react_on_rails/install_generator.rb +58 -1
  7. data/lib/generators/react_on_rails/js_dependency_manager.rb +36 -0
  8. data/lib/generators/react_on_rails/react_no_redux_generator.rb +20 -8
  9. data/lib/generators/react_on_rails/react_with_redux_generator.rb +28 -4
  10. data/lib/generators/react_on_rails/rsc_generator.rb +5 -0
  11. data/lib/generators/react_on_rails/rsc_setup.rb +35 -1
  12. data/lib/generators/react_on_rails/templates/agent_files/.cursor/rules/react-on-rails.mdc +11 -0
  13. data/lib/generators/react_on_rails/templates/agent_files/.github/copilot-instructions.md +7 -0
  14. data/lib/generators/react_on_rails/templates/agent_files/AGENTS.md +164 -0
  15. data/lib/generators/react_on_rails/templates/agent_files/CLAUDE.md +8 -0
  16. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +81 -1
  17. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.jsx +30 -0
  18. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/src/HelloWorld/ror_components/HelloWorld.client.tsx +34 -0
  19. data/lib/generators/react_on_rails/templates/base/tailwind/app/javascript/stylesheets/application.css +1 -0
  20. data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.jsx +25 -0
  21. data/lib/generators/react_on_rails/templates/redux/tailwind/app/javascript/bundles/HelloWorld/components/HelloWorld.tsx +29 -0
  22. data/lib/react_on_rails/doctor.rb +187 -24
  23. data/lib/react_on_rails/font_helper.rb +162 -0
  24. data/lib/react_on_rails/helper.rb +150 -0
  25. data/lib/react_on_rails/smart_error.rb +135 -5
  26. data/lib/react_on_rails/version.rb +1 -1
  27. data/lib/tasks/doctor.rake +5 -2
  28. data/sig/react_on_rails/helper.rbs +3 -0
  29. data/sig/react_on_rails/smart_error.rbs +25 -2
  30. metadata +11 -1
@@ -11,7 +11,87 @@ const commonOptions = {
11
11
  },
12
12
  };
13
13
 
14
+ <% if use_tailwind? -%>
15
+ const tailwindPostcssPlugin = require('@tailwindcss/postcss');
16
+
17
+ const tailwindPostcssLoader = {
18
+ loader: 'postcss-loader',
19
+ options: {
20
+ postcssOptions: {
21
+ plugins: [tailwindPostcssPlugin],
22
+ },
23
+ },
24
+ };
25
+
26
+ const loaderName = (loader) => {
27
+ if (typeof loader === 'string') return loader;
28
+ if (loader && typeof loader.loader === 'string') return loader.loader;
29
+ return '';
30
+ };
31
+
32
+ const postcssLoaderUsesTailwind = (loader) => {
33
+ const plugins = loader?.options?.postcssOptions?.plugins || [];
34
+ const tailwindPluginName = tailwindPostcssPlugin.postcssPlugin || tailwindPostcssPlugin.name;
35
+
36
+ return plugins.some((plugin) => {
37
+ if (plugin === tailwindPostcssPlugin) return true;
38
+ if (!tailwindPluginName) return false;
39
+
40
+ return plugin?.postcssPlugin === tailwindPluginName || plugin?.name === tailwindPluginName;
41
+ });
42
+ };
43
+
44
+ const mergeTailwindPostcssLoader = (loader) => {
45
+ if (typeof loader === 'string') return tailwindPostcssLoader;
46
+ if (postcssLoaderUsesTailwind(loader)) return loader;
47
+
48
+ const existingOptions = loader.options || {};
49
+ const existingPostcssOptions = existingOptions.postcssOptions || {};
50
+ const existingPlugins = existingPostcssOptions.plugins || [];
51
+
52
+ return {
53
+ ...loader,
54
+ options: {
55
+ ...existingOptions,
56
+ postcssOptions: {
57
+ ...existingPostcssOptions,
58
+ plugins: [...existingPlugins, tailwindPostcssPlugin],
59
+ },
60
+ },
61
+ };
62
+ };
63
+
64
+ const addTailwindPostcssLoader = (webpackConfig) => {
65
+ const rules = webpackConfig.module?.rules;
66
+ if (!Array.isArray(rules)) return webpackConfig;
67
+
68
+ rules.forEach((rule) => {
69
+ if (!Array.isArray(rule.use)) return;
70
+
71
+ const postcssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('postcss-loader'));
72
+ if (postcssLoaderIndex !== -1) {
73
+ rule.use[postcssLoaderIndex] = mergeTailwindPostcssLoader(rule.use[postcssLoaderIndex]);
74
+ return;
75
+ }
76
+
77
+ const cssLoaderIndex = rule.use.findIndex((loader) => loaderName(loader).includes('css-loader'));
78
+ if (cssLoaderIndex === -1) return;
79
+
80
+ rule.use.splice(cssLoaderIndex + 1, 0, tailwindPostcssLoader);
81
+ });
82
+
83
+ return webpackConfig;
84
+ };
85
+
86
+ <% end -%>
14
87
  // Copy the object using merge b/c the baseClientWebpackConfig and commonOptions are mutable globals
15
- const commonWebpackConfig = () => merge({}, baseClientWebpackConfig, commonOptions);
88
+ const commonWebpackConfig = () => {
89
+ const webpackConfig = merge({}, baseClientWebpackConfig, commonOptions);
90
+ <% if use_tailwind? -%>
91
+ return addTailwindPostcssLoader(webpackConfig);
92
+ <% else -%>
93
+ return webpackConfig;
94
+ <% end -%>
95
+ };
16
96
 
17
97
  module.exports = commonWebpackConfig;
@@ -0,0 +1,30 @@
1
+ import React, { useState } from 'react';
2
+ import '../../../stylesheets/application.css';
3
+
4
+ const HelloWorld = (props) => {
5
+ const [name, setName] = useState(props.name);
6
+
7
+ return (
8
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
9
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
10
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
11
+ <p className="mt-3 text-sm leading-6 text-slate-600">
12
+ This component is server-rendered by Rails and styled by Tailwind CSS v4.
13
+ </p>
14
+ <form className="mt-5">
15
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
16
+ Say hello to:
17
+ <input
18
+ id="name"
19
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
20
+ type="text"
21
+ value={name}
22
+ onChange={(e) => setName(e.target.value)}
23
+ />
24
+ </label>
25
+ </form>
26
+ </section>
27
+ );
28
+ };
29
+
30
+ export default HelloWorld;
@@ -0,0 +1,34 @@
1
+ import React, { useState } from 'react';
2
+ import '../../../stylesheets/application.css';
3
+
4
+ interface HelloWorldProps {
5
+ name: string;
6
+ }
7
+
8
+ const HelloWorld: React.FC<HelloWorldProps> = (props) => {
9
+ const [name, setName] = useState(props.name);
10
+
11
+ return (
12
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
13
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Tailwind CSS</p>
14
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
15
+ <p className="mt-3 text-sm leading-6 text-slate-600">
16
+ This component is server-rendered by Rails and styled by Tailwind CSS v4.
17
+ </p>
18
+ <form className="mt-5">
19
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
20
+ Say hello to:
21
+ <input
22
+ id="name"
23
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
24
+ type="text"
25
+ value={name}
26
+ onChange={(e) => setName(e.target.value)}
27
+ />
28
+ </label>
29
+ </form>
30
+ </section>
31
+ );
32
+ };
33
+
34
+ export default HelloWorld;
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+
3
+ const HelloWorld = ({ name, updateName }) => (
4
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
5
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
6
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
7
+ <p className="mt-3 text-sm leading-6 text-slate-600">
8
+ This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
9
+ </p>
10
+ <div className="mt-5">
11
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
12
+ Say hello to:
13
+ <input
14
+ id="name"
15
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
16
+ type="text"
17
+ value={name}
18
+ onChange={(e) => updateName(e.target.value)}
19
+ />
20
+ </label>
21
+ </div>
22
+ </section>
23
+ );
24
+
25
+ export default HelloWorld;
@@ -0,0 +1,29 @@
1
+ import React from 'react';
2
+ import type { PropsFromRedux } from '../containers/HelloWorldContainer';
3
+
4
+ // Component props are inferred from Redux container
5
+ type HelloWorldProps = PropsFromRedux;
6
+
7
+ const HelloWorld: React.FC<HelloWorldProps> = ({ name, updateName }) => (
8
+ <section className="mx-auto max-w-xl rounded-lg border border-slate-200 bg-white p-6 text-slate-900 shadow-sm">
9
+ <p className="text-sm font-semibold uppercase text-sky-600">React on Rails + Redux + Tailwind CSS</p>
10
+ <h3 className="mt-2 text-2xl font-bold">Hello, {name}!</h3>
11
+ <p className="mt-3 text-sm leading-6 text-slate-600">
12
+ This Redux-backed component is server-rendered by Rails and styled by Tailwind CSS v4.
13
+ </p>
14
+ <div className="mt-5">
15
+ <label className="block text-sm font-medium text-slate-700" htmlFor="name">
16
+ Say hello to:
17
+ <input
18
+ id="name"
19
+ className="mt-2 w-full rounded-md border border-slate-300 px-3 py-2 shadow-sm focus:border-sky-500 focus:outline-hidden focus:ring-2 focus:ring-sky-200"
20
+ type="text"
21
+ value={name}
22
+ onChange={(e) => updateName(e.target.value)}
23
+ />
24
+ </label>
25
+ </div>
26
+ </section>
27
+ );
28
+
29
+ export default HelloWorld;
@@ -3,9 +3,11 @@
3
3
  require "json"
4
4
  require "erb"
5
5
  require "stringio"
6
+ require "tempfile"
6
7
  require "timeout"
7
8
  require "yaml"
8
9
  require_relative "utils"
10
+ require_relative "version"
9
11
  require_relative "config_path_resolver"
10
12
  require_relative "shakapacker_config_helpers"
11
13
  require_relative "dev/server_mode"
@@ -116,15 +118,69 @@ module ReactOnRails
116
118
  # than this is a sign of an unexpectedly broad pattern, not legitimate config.
117
119
  RENDERER_CACHE_DEPLOY_SCRIPT_GLOB_MAX_MATCHES = 100
118
120
 
119
- def initialize(verbose: false, fix: false)
121
+ # Supported output formats. :text is the human-readable default; :json emits
122
+ # a machine-readable report (see JSON_SCHEMA_VERSION below).
123
+ OUTPUT_FORMATS = %i[text json].freeze
124
+
125
+ # Version of the machine-readable doctor report schema (FORMAT=json).
126
+ # Bump ONLY on breaking changes to the shape below. Schema (v1):
127
+ #
128
+ # {
129
+ # "schema_version": 1,
130
+ # "ror_version": "<ReactOnRails::VERSION>",
131
+ # "status": "pass" | "warn" | "fail", // worst check status
132
+ # "checks": [
133
+ # {
134
+ # "id": "<stable snake_case id from CHECK_SECTIONS>",
135
+ # "title": "<human section title>",
136
+ # "status": "pass" | "warn" | "fail",
137
+ # "message": "<most severe message content, or null>",
138
+ # "details": [ { "level": "success|warning|error|info", "content": "..." } ]
139
+ # }
140
+ # ],
141
+ # "summary": { "pass": <count>, "warn": <count>, "fail": <count> }
142
+ # }
143
+ #
144
+ # No timestamp is included so output is deterministic for a given app state.
145
+ # Exit code semantics match text mode: 1 if any check fails, else 0.
146
+ JSON_SCHEMA_VERSION = 1
147
+
148
+ # Doctor check sections. The :id values are part of the stable JSON schema
149
+ # contract (consumed by agents/tooling) — never rename or reuse them; add new
150
+ # sections with new ids instead.
151
+ CHECK_SECTIONS = [
152
+ { id: "environment_prerequisites", title: "Environment Prerequisites", method: :check_environment },
153
+ { id: "react_on_rails_versions", title: "React on Rails Versions", method: :check_react_on_rails_versions },
154
+ { id: "react_on_rails_packages", title: "React on Rails Packages", method: :check_packages },
155
+ { id: "javascript_package_dependencies", title: "JavaScript Package Dependencies",
156
+ method: :check_dependencies },
157
+ { id: "key_configuration_files", title: "Key Configuration Files", method: :check_key_files },
158
+ { id: "configuration_analysis", title: "Configuration Analysis", method: :check_configuration_details },
159
+ { id: "bin_dev_launcher_setup", title: "bin/dev Launcher Setup", method: :check_bin_dev_launcher },
160
+ { id: "rails_integration", title: "Rails Integration", method: :check_rails },
161
+ { id: "bundler_configuration", title: "Bundler Configuration", method: :check_bundler_configuration },
162
+ { id: "testing_setup", title: "Testing Setup", method: :check_testing_setup },
163
+ { id: "development_environment", title: "Development Environment", method: :check_development },
164
+ { id: "react_on_rails_pro_setup", title: "React on Rails Pro Setup", method: :check_pro_setup },
165
+ { id: "react_server_components", title: "React Server Components", method: :check_rsc_setup }
166
+ ].freeze
167
+
168
+ def initialize(verbose: false, fix: false, format: :text)
120
169
  @verbose = verbose
121
170
  @fix = fix
171
+ @format = format.respond_to?(:to_sym) ? format.to_sym : format
172
+ unless OUTPUT_FORMATS.include?(@format)
173
+ raise ArgumentError, "Invalid doctor format #{format.inspect}; expected one of #{OUTPUT_FORMATS.join(', ')}"
174
+ end
175
+
122
176
  @checker = SystemChecker.new
123
177
  @test_output_path_strategy = :unknown
124
178
  @rails_environment_loaded = false
125
179
  end
126
180
 
127
181
  def run_diagnosis
182
+ return run_json_diagnosis if format == :json
183
+
128
184
  print_header
129
185
  run_all_checks
130
186
  print_summary
@@ -135,7 +191,7 @@ module ReactOnRails
135
191
 
136
192
  private
137
193
 
138
- attr_reader :verbose, :fix, :checker
194
+ attr_reader :verbose, :fix, :format, :checker
139
195
 
140
196
  def print_header
141
197
  puts Rainbow("\n#{'=' * 80}").cyan
@@ -156,35 +212,139 @@ module ReactOnRails
156
212
  end
157
213
 
158
214
  def run_all_checks
159
- checks = [
160
- ["Environment Prerequisites", :check_environment],
161
- ["React on Rails Versions", :check_react_on_rails_versions],
162
- ["React on Rails Packages", :check_packages],
163
- ["JavaScript Package Dependencies", :check_dependencies],
164
- ["Key Configuration Files", :check_key_files],
165
- ["Configuration Analysis", :check_configuration_details],
166
- ["bin/dev Launcher Setup", :check_bin_dev_launcher],
167
- ["Rails Integration", :check_rails],
168
- ["Bundler Configuration", :check_bundler_configuration],
169
- ["Testing Setup", :check_testing_setup],
170
- ["Development Environment", :check_development],
171
- ["React on Rails Pro Setup", :check_pro_setup],
172
- ["React Server Components", :check_rsc_setup]
173
- ]
174
-
175
- checks.each do |section_name, check_method|
215
+ CHECK_SECTIONS.each do |section|
176
216
  initial_message_count = checker.messages.length
177
- send(check_method)
217
+ send(section[:method])
178
218
 
179
219
  # Only print header if messages were added
180
220
  next unless checker.messages.length > initial_message_count
181
221
 
182
- print_section_header(section_name)
222
+ print_section_header(section[:title])
183
223
  print_recent_messages(initial_message_count)
184
224
  puts
185
225
  end
186
226
  end
187
227
 
228
+ # JSON output mode: stdout carries ONLY the JSON report (schema documented
229
+ # at JSON_SCHEMA_VERSION); any stray output produced by checks is routed to
230
+ # stderr so the report stays parseable.
231
+ def run_json_diagnosis
232
+ results = nil
233
+ stray_output = capture_stdout { results = collect_check_results }
234
+ $stderr.print(stray_output) unless stray_output.empty?
235
+
236
+ puts JSON.pretty_generate(build_json_report(results))
237
+ exit(diagnosis_exit_code)
238
+ end
239
+
240
+ def collect_check_results
241
+ CHECK_SECTIONS.map do |section|
242
+ initial_message_count = checker.messages.length
243
+ send(section[:method])
244
+
245
+ {
246
+ id: section[:id],
247
+ title: section[:title],
248
+ messages: checker.messages[initial_message_count..]
249
+ }
250
+ end
251
+ end
252
+
253
+ # Captures everything written to the stdout file descriptor (fd 1) while the
254
+ # block runs — including direct STDOUT writes, child processes inheriting
255
+ # fd 1, and C extensions — not just Ruby-level $stdout. Reassigning $stdout
256
+ # to a StringIO would miss those, letting stray bytes corrupt the JSON report.
257
+ #
258
+ # NOT thread-safe: STDOUT.reopen redirects fd 1 process-wide, so any
259
+ # concurrent thread writing to stdout is captured too. Doctor runs only as
260
+ # a single-threaded rake task today; revisit before using in threaded code.
261
+ # rubocop:disable Style/GlobalStdStream
262
+ def capture_stdout
263
+ captured_file = Tempfile.new("react_on_rails_doctor_stdout")
264
+ original_stdout = $stdout
265
+ original_fd = STDOUT.dup
266
+ STDOUT.flush
267
+ STDOUT.reopen(captured_file.path, "w")
268
+ $stdout = STDOUT
269
+ yield
270
+ STDOUT.flush
271
+ File.read(captured_file.path)
272
+ ensure
273
+ begin
274
+ STDOUT.flush
275
+ rescue IOError, SystemCallError
276
+ nil
277
+ end
278
+ if original_fd
279
+ begin
280
+ STDOUT.reopen(original_fd)
281
+ ensure
282
+ original_fd.close
283
+ end
284
+ end
285
+ $stdout = original_stdout if original_stdout
286
+ captured_file&.close!
287
+ end
288
+ # rubocop:enable Style/GlobalStdStream
289
+
290
+ def build_json_report(results)
291
+ checks = results.map { |result| build_check_entry(result) }
292
+ statuses = checks.map { |check| check[:status] }
293
+
294
+ {
295
+ schema_version: JSON_SCHEMA_VERSION,
296
+ ror_version: ReactOnRails::VERSION,
297
+ status: overall_status(statuses),
298
+ checks:,
299
+ summary: {
300
+ pass: statuses.count("pass"),
301
+ warn: statuses.count("warn"),
302
+ fail: statuses.count("fail")
303
+ }
304
+ }
305
+ end
306
+
307
+ def build_check_entry(result)
308
+ messages = result[:messages]
309
+ status = check_status(messages)
310
+
311
+ {
312
+ id: result[:id],
313
+ title: result[:title],
314
+ status:,
315
+ message: primary_message(messages, status),
316
+ details: messages.map { |message| { level: message[:type].to_s, content: message[:content] } }
317
+ }
318
+ end
319
+
320
+ def check_status(messages)
321
+ if messages.any? { |message| message[:type] == :error }
322
+ "fail"
323
+ elsif messages.any? { |message| message[:type] == :warning }
324
+ "warn"
325
+ else
326
+ "pass"
327
+ end
328
+ end
329
+
330
+ def overall_status(statuses)
331
+ if statuses.include?("fail")
332
+ "fail"
333
+ elsif statuses.include?("warn")
334
+ "warn"
335
+ else
336
+ "pass"
337
+ end
338
+ end
339
+
340
+ def primary_message(messages, status)
341
+ severity = { "fail" => :error, "warn" => :warning }[status]
342
+ return nil unless severity
343
+
344
+ message = messages.find { |msg| msg[:type] == severity }
345
+ message && message[:content]
346
+ end
347
+
188
348
  def print_section_header(section_name)
189
349
  puts Rainbow("#{section_name}:").blue.bold
190
350
  puts Rainbow("-" * (section_name.length + 1)).blue
@@ -1765,14 +1925,17 @@ module ReactOnRails
1765
1925
  def exit_with_status
1766
1926
  if checker.errors?
1767
1927
  puts Rainbow("❌ Doctor found critical issues. Please address errors above.").red.bold
1768
- exit(1)
1769
1928
  elsif checker.warnings?
1770
1929
  puts Rainbow("⚠️ Doctor found some issues. Consider addressing warnings above.").yellow
1771
- exit(0)
1772
1930
  else
1773
1931
  puts Rainbow("🎉 All checks passed! Your React on Rails setup is healthy.").green.bold
1774
- exit(0)
1775
1932
  end
1933
+
1934
+ exit(diagnosis_exit_code)
1935
+ end
1936
+
1937
+ def diagnosis_exit_code
1938
+ checker.errors? ? 1 : 0
1776
1939
  end
1777
1940
 
1778
1941
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module ReactOnRails
6
+ # First-party font optimization helper, a `next/font/local` analog for Rails.
7
+ #
8
+ # Given a self-hosted (committed + fingerprinted) `.woff2` file already served by
9
+ # your asset pipeline, this returns the markup an ERB view should place in the
10
+ # document `<head>`:
11
+ #
12
+ # 1. `<link rel="preload" as="font" type="font/woff2" crossorigin>` so the
13
+ # browser fetches the font in parallel with the first paint;
14
+ # 2. an `@font-face` rule with `font-display: swap` so text renders immediately
15
+ # with a fallback and swaps in the web font when ready;
16
+ # 3. an optional metric-matched fallback `@font-face` (`size-adjust` plus
17
+ # `ascent-override` / `descent-override` / `line-gap-override`) so the system
18
+ # fallback occupies the same space as the web font, eliminating the layout
19
+ # shift (CLS) that normally happens on the swap.
20
+ #
21
+ # It mirrors the `<head>`-injection convention used by `react_component_hash`
22
+ # (see `ReactOnRails::Helper`): the caller wraps the return value in
23
+ # `content_for :head`, and the layout yields it inside `<head>`.
24
+ #
25
+ # Example (ERB view):
26
+ #
27
+ # <% content_for :head do %>
28
+ # <%= react_on_rails_font_face(
29
+ # family: "Inter",
30
+ # src: asset_path("inter-latin-400-normal.woff2"),
31
+ # weight: 400,
32
+ # fallback: {
33
+ # family: "Arial",
34
+ # size_adjust: "107.12%",
35
+ # ascent_override: "90.44%",
36
+ # descent_override: "22.52%",
37
+ # line_gap_override: "0.0%"
38
+ # }
39
+ # ) %>
40
+ # <% end %>
41
+ #
42
+ # Then set the CSS font stack to the web font followed by its fallback face, e.g.
43
+ # `font-family: "Inter", "Inter Fallback", sans-serif;`.
44
+ module FontHelper
45
+ extend ActiveSupport::Concern
46
+
47
+ # CSS/HTML metacharacters that would let an argument break out of the
48
+ # `<style>` / `<link>` context this helper emits.
49
+ UNSAFE_TOKEN = /[<>"\r\n]/
50
+
51
+ # Emits the preload `<link>`, the primary `@font-face`, and (when `fallback:`
52
+ # is supplied) a metric-matched fallback `@font-face`.
53
+ #
54
+ # @param family [String] CSS font-family name for the web font (e.g. "Inter").
55
+ # @param src [String] URL/path to the `.woff2` (typically `asset_path(...)`).
56
+ # @param weight [Integer, String] `font-weight` (default 400). A range like
57
+ # "100 900" is valid for variable fonts.
58
+ # @param style [String] `font-style` (default "normal").
59
+ # @param display [String] `font-display` (default "swap").
60
+ # @param unicode_range [String, nil] optional `unicode-range` to subset the face.
61
+ # @param preload [Boolean] emit the preload `<link>` (default true).
62
+ # @param fallback [Hash, nil] metric-matched fallback face. Keys:
63
+ # :family (required, the local system font, e.g. "Arial"),
64
+ # :name (the generated face name, default "#{family} Fallback"),
65
+ # :size_adjust, :ascent_override, :descent_override, :line_gap_override.
66
+ # @return [ActiveSupport::SafeBuffer] head markup, ready for `content_for :head`.
67
+ #
68
+ # @note SECURITY: arguments are interpolated verbatim into the trusted CSS/HTML
69
+ # emitted into `<head>` and the result is marked `html_safe`. Pass only
70
+ # developer-controlled values (font names, asset paths), never end-user input.
71
+ # Values containing `<`, `>`, `"`, or a newline raise `ArgumentError`.
72
+ def react_on_rails_font_face(family:, src:, weight: 400, style: "normal", display: "swap",
73
+ unicode_range: nil, preload: true, fallback: nil)
74
+ ReactOnRails::FontHelper.font_face_markup(
75
+ family:, src:, weight:, style:, display:,
76
+ unicode_range:, preload:, fallback:
77
+ ).html_safe
78
+ end
79
+
80
+ # Pure-string builder so the markup can be unit-tested without a view context.
81
+ # Returns a plain (not html_safe) String.
82
+ def self.font_face_markup(family:, src:, weight: 400, style: "normal", display: "swap",
83
+ unicode_range: nil, preload: true, fallback: nil)
84
+ # Validate every value interpolated into the trusted <style>/<link> markup so no
85
+ # argument can break out of the CSS/HTML context (see the contract in the docstring).
86
+ ensure_safe!("family", family)
87
+ ensure_safe!("src", src)
88
+ ensure_safe!("weight", weight)
89
+ ensure_safe!("style", style)
90
+ ensure_safe!("display", display)
91
+ ensure_safe!("unicode_range", unicode_range) if unicode_range
92
+ if fallback
93
+ ensure_safe!("fallback[:family]", fallback.fetch(:family))
94
+ ensure_safe!("fallback[:name]", fallback[:name]) if fallback[:name]
95
+ %i[size_adjust ascent_override descent_override line_gap_override].each do |key|
96
+ ensure_safe!("fallback[:#{key}]", fallback[key]) if fallback[key]
97
+ end
98
+ end
99
+ rule = font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
100
+ parts = []
101
+ parts << preload_link(src) if preload
102
+ parts << +"<style>\n#{rule}"
103
+ parts[-1] << "\n#{fallback_font_face_rule(family, fallback, weight:, style:)}" if fallback
104
+ parts[-1] << "\n</style>"
105
+ parts.join("\n")
106
+ end
107
+
108
+ # Rejects values that could break out of the CSS/HTML context. Font helper
109
+ # arguments are emitted into trusted `<head>` markup, so they must be
110
+ # developer-controlled, not end-user input.
111
+ def self.ensure_safe!(name, value)
112
+ return unless value.to_s.match?(UNSAFE_TOKEN)
113
+
114
+ raise ArgumentError,
115
+ "react_on_rails_font_face: #{name}=#{value.inspect} contains an unsafe character " \
116
+ "(<, >, \", or newline); font arguments must be developer-controlled, not end-user input."
117
+ end
118
+
119
+ def self.preload_link(src)
120
+ %(<link rel="preload" href="#{src}" as="font" type="font/woff2" crossorigin="anonymous">)
121
+ end
122
+
123
+ def self.font_face_rule(family:, src:, weight:, style:, display:, unicode_range:)
124
+ lines = [
125
+ "@font-face {",
126
+ %( font-family: "#{family}";),
127
+ %( src: url("#{src}") format("woff2");),
128
+ " font-weight: #{weight};",
129
+ " font-style: #{style};",
130
+ " font-display: #{display};"
131
+ ]
132
+ lines << " unicode-range: #{unicode_range};" if unicode_range
133
+ lines << "}"
134
+ lines.join("\n")
135
+ end
136
+
137
+ # Generates the metric-matched fallback face. Hardcode `size-adjust` and the
138
+ # override percentages from the font's published metrics (see the fonts docs
139
+ # for how the Inter-over-Arial numbers are derived). The `font-weight` and
140
+ # `font-style` mirror the primary face so the browser matches this fallback to
141
+ # the same elements (otherwise the size-adjust CLS protection silently fails for
142
+ # non-400 weights / non-normal styles), the way `next/font` generates its
143
+ # weight-matched fallback.
144
+ def self.fallback_font_face_rule(family, fallback, weight:, style:)
145
+ name = fallback[:name] || "#{family} Fallback"
146
+ local = fallback.fetch(:family)
147
+ lines = [
148
+ "@font-face {",
149
+ %( font-family: "#{name}";),
150
+ %( src: local("#{local}");),
151
+ " font-weight: #{weight};",
152
+ " font-style: #{style};"
153
+ ]
154
+ lines << " size-adjust: #{fallback[:size_adjust]};" if fallback[:size_adjust]
155
+ lines << " ascent-override: #{fallback[:ascent_override]};" if fallback[:ascent_override]
156
+ lines << " descent-override: #{fallback[:descent_override]};" if fallback[:descent_override]
157
+ lines << " line-gap-override: #{fallback[:line_gap_override]};" if fallback[:line_gap_override]
158
+ lines << "}"
159
+ lines.join("\n")
160
+ end
161
+ end
162
+ end