react_on_rails 16.4.0.rc.7 → 16.4.0.rc.8

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a834e70a65aa260461effa652dcea303bc28acb1888b8f81176b190a1234359b
4
- data.tar.gz: 11c61171bcc86b52fa4b376550183f28916894f84e212395a6ad88777195bd0b
3
+ metadata.gz: 8836b6e37dab21e6ee52f045e1f1dbb0ddce044107ce4ff6b48724ac57003bb9
4
+ data.tar.gz: 3deef582db0eccf9d692813848f8dc0e1f7c610f819497bb563fb6de0b5b2091
5
5
  SHA512:
6
- metadata.gz: 42b6d70052957bda4047e034a44aaffdafe78fae4190f141b9c624294be1e685f0e4a1833b41db17605771bbca917ca05e78d22bd02090ac8cd56dee77065e0a
7
- data.tar.gz: 158a713f868d6563a1f89eb0c4e2ddf355bbda5c6341df447648681588e1164ae93ca1941e8aa8a4844a8a57661bae4cbb31845aa9855229fff5559e134db432
6
+ metadata.gz: 38982dfff467e79045d3c6017a991f8dedac9c17abee867cb55197e2514cbe238317ec99ef89e5ad0c258a7d306bf8325ea1266926ae85a98111af1f759023fb
7
+ data.tar.gz: 124b251b8a0ca66b4f38a8d2990012690be96408c768a4f1081ad43f87c6d73b525a5bfd2de56d023a138288a28f5dbf6a6f988bdcbab8dd8bf7f1e2ea585be7
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- react_on_rails (16.4.0.rc.7)
4
+ react_on_rails (16.4.0.rc.8)
5
5
  addressable
6
6
  connection_pool
7
7
  execjs (~> 2.5)
@@ -53,17 +53,19 @@ module ReactOnRails
53
53
  # to handle all JS dependency installation via package_json gem.
54
54
  module JsDependencyManager
55
55
  # Core React dependencies required for React on Rails
56
- # Note: @babel/preset-react and babel plugins are NOT included here because:
57
- # - Shakapacker handles JavaScript transpiler configuration (babel, swc, or esbuild)
58
- # - Users configure their preferred transpiler via shakapacker.yml javascript_transpiler setting
59
- # - SWC is now the default and doesn't need Babel presets
60
- # - For Babel users, shakapacker will install babel-loader and its dependencies
56
+ # Note: @babel/preset-react is handled separately in BABEL_REACT_DEPENDENCIES
57
+ # and is added only when SWC is not the active transpiler.
61
58
  REACT_DEPENDENCIES = %w[
62
59
  react
63
60
  react-dom
64
61
  prop-types
65
62
  ].freeze
66
63
 
64
+ # Babel preset needed by the generated babel.config.js for non-SWC setups.
65
+ BABEL_REACT_DEPENDENCIES = %w[
66
+ @babel/preset-react
67
+ ].freeze
68
+
67
69
  # CSS processing dependencies for webpack
68
70
  CSS_DEPENDENCIES = %w[
69
71
  css-loader
@@ -144,12 +146,17 @@ module ReactOnRails
144
146
  add_react_dependencies
145
147
  add_css_dependencies
146
148
  add_rspack_dependencies if using_rspack?
147
- add_swc_dependencies if using_swc?
149
+ add_transpiler_dependencies
148
150
  add_pro_dependencies if using_pro
149
151
  add_rsc_dependencies if using_rsc
150
152
  add_dev_dependencies
151
153
  end
152
154
 
155
+ def add_transpiler_dependencies
156
+ add_swc_dependencies if using_swc?
157
+ add_babel_react_dependencies if !using_swc? && !using_rspack?
158
+ end
159
+
153
160
  def add_react_on_rails_package
154
161
  # Use exact version match between gem and npm package for all versions including pre-releases
155
162
  # Ruby gem versions use dots (16.2.0.beta.10) but npm requires hyphens (16.2.0-beta.10)
@@ -289,6 +296,25 @@ module ReactOnRails
289
296
  MSG
290
297
  end
291
298
 
299
+ def add_babel_react_dependencies
300
+ puts "Installing Babel React preset dependency..."
301
+ return if add_packages(BABEL_REACT_DEPENDENCIES, dev: true)
302
+
303
+ GeneratorMessages.add_warning(<<~MSG.strip)
304
+ ⚠️ Failed to add Babel React preset dependency.
305
+
306
+ You can install it manually by running:
307
+ npm install --save-dev #{BABEL_REACT_DEPENDENCIES.join(' ')}
308
+ MSG
309
+ rescue StandardError => e
310
+ GeneratorMessages.add_warning(<<~MSG.strip)
311
+ ⚠️ Error adding Babel React preset dependency: #{e.message}
312
+
313
+ You can install it manually by running:
314
+ npm install --save-dev #{BABEL_REACT_DEPENDENCIES.join(' ')}
315
+ MSG
316
+ end
317
+
292
318
  def add_typescript_dependencies
293
319
  puts "Installing TypeScript dependencies..."
294
320
  return if add_packages(TYPESCRIPT_DEPENDENCIES, dev: true)
@@ -304,6 +304,8 @@ module ReactOnRails
304
304
  end
305
305
  end
306
306
 
307
+ add_csp_nonce_to_context(result)
308
+
307
309
  if defined?(request) && request.present?
308
310
  # Check for encoding of the request's original_url and try to force-encoding the
309
311
  # URLs as UTF-8. This situation can occur in browsers that do not encode the
@@ -341,6 +343,11 @@ module ReactOnRails
341
343
  @rails_context.merge(serverSide: server_side)
342
344
  end
343
345
 
346
+ def add_csp_nonce_to_context(result)
347
+ nonce = csp_nonce
348
+ result[:cspNonce] = nonce if nonce.present?
349
+ end
350
+
344
351
  def load_pack_for_generated_component(react_component_name, render_options)
345
352
  return unless render_options.auto_load_bundle
346
353
 
@@ -1,15 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "erb"
4
3
  require "pathname"
5
4
  require "shakapacker"
6
- require "yaml"
7
5
 
8
6
  module ReactOnRails
9
- # rubocop:disable Metrics/ModuleLength
10
7
  module PackerUtils
11
- SHAKAPACKER_CONFIG_PATH = File.join("config", "shakapacker.yml")
12
-
13
8
  def self.dev_server_running?
14
9
  Shakapacker.dev_server.running?
15
10
  end
@@ -185,20 +180,21 @@ module ReactOnRails
185
180
  end
186
181
 
187
182
  def self.extract_precompile_hook
188
- # Prefer using Shakapacker's runtime config when available.
189
- # In bin/dev startup before Rails boots, this can raise (e.g., missing Rails.env),
190
- # so we rescue and fall back to parsing config/shakapacker.yml directly.
191
- hook_value = extract_precompile_hook_from_shakapacker_config
192
- return hook_value unless hook_value.nil?
183
+ # Prefer the public API (available in Shakapacker 9.0+)
184
+ return ::Shakapacker.config.precompile_hook if ::Shakapacker.config.respond_to?(:precompile_hook)
185
+
186
+ # Fallback: access config data using private :data method
187
+ config_data = ::Shakapacker.config.send(:data)
193
188
 
194
- extract_precompile_hook_from_yaml
189
+ # Try symbol keys first (Shakapacker's internal format), then fall back to string keys
190
+ config_data&.[](:precompile_hook) || config_data&.[]("precompile_hook")
195
191
  end
196
192
 
197
193
  # Regex pattern to detect pack generation in hook scripts
198
194
  # Matches both:
199
195
  # - The rake task: react_on_rails:generate_packs
200
- # - The Ruby methods: generate_packs_if_stale / generate_packs_if_needed
201
- GENERATE_PACKS_PATTERN = /\b(react_on_rails:generate_packs|generate_packs_if_stale|generate_packs_if_needed)\b/
196
+ # - The Ruby method: generate_packs_if_stale (used by generator template)
197
+ GENERATE_PACKS_PATTERN = /\b(react_on_rails:generate_packs|generate_packs_if_stale)\b/
202
198
 
203
199
  # Pattern to detect a real self-guard statement that exits early when
204
200
  # SHAKAPACKER_SKIP_PRECOMPILE_HOOK is true. This avoids false positives
@@ -221,7 +217,7 @@ module ReactOnRails
221
217
 
222
218
  # Check if it's a script file path
223
219
  script_path = resolve_hook_script_path(hook_value)
224
- return false unless script_path
220
+ return false unless script_path && File.exist?(script_path)
225
221
 
226
222
  # Read and check script contents
227
223
  script_contents = File.read(script_path)
@@ -234,29 +230,20 @@ module ReactOnRails
234
230
  def self.resolve_hook_script_path(hook_value)
235
231
  return nil if hook_value.blank?
236
232
 
237
- hook_path = hook_value.to_s.strip
238
- return nil if hook_path.empty?
239
-
240
- # Strip interpreter prefix (e.g., "ruby bin/hook" -> "bin/hook")
241
- hook_path = extract_script_from_command(hook_path) || hook_path
242
-
243
- # Hook value might be a script path relative to project root.
244
- # project_root prefers Rails.root and otherwise derives from BUNDLE_GEMFILE or cwd.
245
- potential_path = project_root.join(hook_path)
233
+ potential_path = project_root.join(hook_value.to_s.strip)
246
234
  potential_path if potential_path.file?
247
235
  end
248
236
 
249
- # Extract the script path from an interpreter-prefixed command.
250
- # e.g., "ruby bin/shakapacker-precompile-hook" -> "bin/shakapacker-precompile-hook"
251
- # Returns nil if the value doesn't look like an interpreter-prefixed command.
252
- def self.extract_script_from_command(command)
253
- parts = command.strip.split(/\s+/, 2)
254
- return nil unless parts.length == 2
237
+ def self.project_root
238
+ return Rails.root if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
255
239
 
256
- interpreter = File.basename(parts[0])
257
- return parts[1] if %w[ruby node bash sh].include?(interpreter)
240
+ bundle_gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
241
+ if bundle_gemfile && !bundle_gemfile.strip.empty?
242
+ gemfile_path = Pathname.new(bundle_gemfile).expand_path
243
+ return gemfile_path.dirname if gemfile_path.file?
244
+ end
258
245
 
259
- nil
246
+ Pathname.new(Dir.pwd)
260
247
  end
261
248
 
262
249
  # Check if a hook script file contains the self-guard pattern that prevents
@@ -283,79 +270,5 @@ module ReactOnRails
283
270
  rescue StandardError
284
271
  nil
285
272
  end
286
-
287
- def self.extract_precompile_hook_from_shakapacker_config
288
- # Prefer the public API (available in Shakapacker 9.0+)
289
- return ::Shakapacker.config.precompile_hook if ::Shakapacker.config.respond_to?(:precompile_hook)
290
-
291
- # Fallback: access config data using private :data method
292
- config_data = ::Shakapacker.config.send(:data)
293
-
294
- # Try symbol keys first (Shakapacker's internal format), then fall back to string keys
295
- if config_data&.key?(:precompile_hook)
296
- config_data[:precompile_hook]
297
- elsif config_data&.key?("precompile_hook")
298
- config_data["precompile_hook"]
299
- end
300
- rescue StandardError
301
- nil
302
- end
303
-
304
- def self.extract_precompile_hook_from_yaml
305
- config_path = project_root.join(SHAKAPACKER_CONFIG_PATH)
306
- return nil unless config_path.file?
307
-
308
- yaml_content = ERB.new(File.read(config_path)).result
309
- config_data = YAML.safe_load(yaml_content, permitted_classes: [Symbol], aliases: true) || {}
310
-
311
- env_config = extract_hash_for_environment(config_data, current_shakapacker_environment)
312
- return env_config["precompile_hook"] if env_config.key?("precompile_hook")
313
- return env_config[:precompile_hook] if env_config.key?(:precompile_hook)
314
-
315
- default_config = extract_hash_for_environment(config_data, "default")
316
- return default_config["precompile_hook"] if default_config.key?("precompile_hook")
317
- return default_config[:precompile_hook] if default_config.key?(:precompile_hook)
318
-
319
- nil
320
- rescue StandardError
321
- nil
322
- end
323
-
324
- def self.extract_hash_for_environment(config_data, env_name)
325
- value = config_data[env_name] || config_data[env_name.to_sym]
326
- value.is_a?(Hash) ? value : {}
327
- end
328
-
329
- def self.current_shakapacker_environment
330
- if defined?(Rails) && Rails.respond_to?(:env)
331
- env = begin
332
- Rails.env.to_s
333
- rescue StandardError
334
- nil
335
- end
336
- return env unless env.to_s.strip.empty?
337
- end
338
-
339
- rails_env = ENV.fetch("RAILS_ENV", nil)
340
- return rails_env unless rails_env.to_s.strip.empty?
341
-
342
- rack_env = ENV.fetch("RACK_ENV", nil)
343
- return rack_env unless rack_env.to_s.strip.empty?
344
-
345
- "development"
346
- end
347
-
348
- def self.project_root
349
- return Rails.root if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
350
-
351
- bundle_gemfile = ENV.fetch("BUNDLE_GEMFILE", nil)
352
- if bundle_gemfile && !bundle_gemfile.strip.empty?
353
- gemfile_path = Pathname.new(bundle_gemfile).expand_path
354
- return gemfile_path.dirname if gemfile_path.file?
355
- end
356
-
357
- Pathname.new(Dir.pwd)
358
- end
359
273
  end
360
- # rubocop:enable Metrics/ModuleLength
361
274
  end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
3
4
  require "open3"
5
+ require "yaml"
4
6
 
5
7
  module ReactOnRails
6
8
  # SystemChecker provides validation methods for React on Rails setup
@@ -186,14 +188,13 @@ module ReactOnRails
186
188
  return unless File.exist?(package_json_path)
187
189
 
188
190
  package_json = JSON.parse(File.read(package_json_path))
189
- npm_version = package_json.dig("dependencies", "react-on-rails") ||
190
- package_json.dig("devDependencies", "react-on-rails")
191
+ package_name, npm_version = react_on_rails_npm_package_details(package_json)
191
192
 
192
- if npm_version
193
- add_success("✅ react-on-rails NPM package #{npm_version} is declared")
193
+ if package_name
194
+ add_success("✅ #{package_name} NPM package #{npm_version} is declared")
194
195
  else
195
196
  add_warning(<<~MSG.strip)
196
- ⚠️ react-on-rails NPM package not found in package.json.
197
+ ⚠️ Neither react-on-rails nor react-on-rails-pro NPM package found in package.json.
197
198
 
198
199
  Install it with:
199
200
  npm install react-on-rails
@@ -208,8 +209,7 @@ module ReactOnRails
208
209
 
209
210
  begin
210
211
  package_json = JSON.parse(File.read("package.json"))
211
- npm_version = package_json.dig("dependencies", "react-on-rails") ||
212
- package_json.dig("devDependencies", "react-on-rails")
212
+ package_name, npm_version = react_on_rails_npm_package_details(package_json)
213
213
 
214
214
  return unless npm_version && defined?(ReactOnRails::VERSION)
215
215
 
@@ -221,7 +221,7 @@ module ReactOnRails
221
221
  gem_version = ReactOnRails::VERSION
222
222
 
223
223
  if normalized_npm_version == gem_version
224
- add_success("✅ React on Rails gem and NPM package versions match (#{gem_version})")
224
+ add_success("✅ React on Rails gem and #{package_name} NPM package versions match (#{gem_version})")
225
225
  check_version_patterns(npm_version, gem_version)
226
226
  else
227
227
  # Check for major version differences
@@ -232,7 +232,7 @@ module ReactOnRails
232
232
  add_error(<<~MSG.strip)
233
233
  🚫 Major version mismatch detected:
234
234
  • Gem version: #{gem_version} (major: #{gem_major})
235
- NPM version: #{npm_version} (major: #{npm_major})
235
+ #{package_name} version: #{npm_version} (major: #{npm_major})
236
236
 
237
237
  Major version differences can cause serious compatibility issues.
238
238
  Update both packages to use the same major version immediately.
@@ -241,7 +241,7 @@ module ReactOnRails
241
241
  add_warning(<<~MSG.strip)
242
242
  ⚠️ Version mismatch detected:
243
243
  • Gem version: #{gem_version}
244
- NPM version: #{npm_version}
244
+ #{package_name} version: #{npm_version}
245
245
 
246
246
  Consider updating to exact, fixed matching versions of gem and npm package for best compatibility.
247
247
  MSG
@@ -341,7 +341,7 @@ module ReactOnRails
341
341
 
342
342
  begin
343
343
  package_json = JSON.parse(File.read("package.json"))
344
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
344
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
345
345
  all_deps["webpack-bundle-analyzer"]
346
346
  rescue StandardError
347
347
  false
@@ -373,6 +373,14 @@ module ReactOnRails
373
373
 
374
374
  private
375
375
 
376
+ def react_on_rails_npm_package_details(package_json)
377
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
378
+ return ["react-on-rails-pro", all_deps["react-on-rails-pro"]] if all_deps["react-on-rails-pro"]
379
+ return ["react-on-rails", all_deps["react-on-rails"]] if all_deps["react-on-rails"]
380
+
381
+ [nil, nil]
382
+ end
383
+
376
384
  def node_missing?
377
385
  command = ReactOnRails::Utils.running_on_windows? ? "where" : "which"
378
386
  _stdout, _stderr, status = Open3.capture3(command, "node")
@@ -449,11 +457,53 @@ module ReactOnRails
449
457
  end
450
458
 
451
459
  def required_react_dependencies
452
- {
460
+ deps = {
453
461
  "react" => "React library",
454
- "react-dom" => "React DOM library",
455
- "@babel/preset-react" => "Babel React preset"
462
+ "react-dom" => "React DOM library"
456
463
  }
464
+
465
+ deps["@babel/preset-react"] = "Babel React preset" if using_babel_transpiler?
466
+ deps
467
+ end
468
+
469
+ def using_babel_transpiler?
470
+ transpiler = detected_javascript_transpiler
471
+ return true if transpiler.nil?
472
+
473
+ transpiler == "babel"
474
+ end
475
+
476
+ def detected_javascript_transpiler
477
+ config = parsed_shakapacker_config
478
+ unless config
479
+ if File.exist?("config/shakapacker.yml")
480
+ add_info("ℹ️ Unable to parse config/shakapacker.yml — defaulting to Babel assumption")
481
+ end
482
+ return nil
483
+ end
484
+
485
+ rails_env = ENV["RAILS_ENV"] || ENV["RACK_ENV"] || "development"
486
+ env_config = config[rails_env] || {}
487
+ default_config = config["default"] || {}
488
+ transpiler = env_config["javascript_transpiler"] || default_config["javascript_transpiler"]
489
+ normalize_transpiler_value(transpiler)
490
+ end
491
+
492
+ def parsed_shakapacker_config
493
+ shakapacker_config_path = "config/shakapacker.yml"
494
+ return nil unless File.exist?(shakapacker_config_path)
495
+
496
+ raw_content = File.read(shakapacker_config_path)
497
+ rendered_content = ERB.new(raw_content).result
498
+ parsed = YAML.safe_load(rendered_content, aliases: true)
499
+ parsed.is_a?(Hash) ? parsed : nil
500
+ rescue StandardError, ScriptError
501
+ nil
502
+ end
503
+
504
+ def normalize_transpiler_value(transpiler)
505
+ normalized = transpiler.to_s.strip.downcase
506
+ normalized.empty? ? nil : normalized
457
507
  end
458
508
 
459
509
  def additional_build_dependencies
@@ -468,10 +518,10 @@ module ReactOnRails
468
518
  }
469
519
  end
470
520
 
471
- # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
521
+ # rubocop:disable Metrics/CyclomaticComplexity
472
522
  def check_build_dependencies(package_json)
473
523
  build_deps = additional_build_dependencies
474
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
524
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
475
525
 
476
526
  present_deps = []
477
527
  missing_deps = []
@@ -496,7 +546,7 @@ module ReactOnRails
496
546
  suffix = missing_deps.length > 3 ? "..." : ""
497
547
  add_info("ℹ️ Optional build dependencies: #{short_list}#{suffix}")
498
548
  end
499
- # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
549
+ # rubocop:enable Metrics/CyclomaticComplexity
500
550
 
501
551
  def parse_package_json
502
552
  JSON.parse(File.read("package.json"))
@@ -506,12 +556,12 @@ module ReactOnRails
506
556
  end
507
557
 
508
558
  def find_missing_dependencies(package_json, required_deps)
509
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
559
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
510
560
  required_deps.keys.reject { |dep| all_deps[dep] }
511
561
  end
512
562
 
513
563
  def report_dependency_status(required_deps, missing_deps, package_json)
514
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
564
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
515
565
 
516
566
  required_deps.each do |dep, description|
517
567
  add_success("✅ #{description} (#{dep}) is installed") if all_deps[dep]
@@ -572,7 +622,7 @@ module ReactOnRails
572
622
  end
573
623
 
574
624
  def report_dependency_versions(package_json)
575
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
625
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
576
626
 
577
627
  react_version = all_deps["react"]
578
628
  react_dom_version = all_deps["react-dom"]
@@ -643,7 +693,7 @@ module ReactOnRails
643
693
 
644
694
  begin
645
695
  package_json = JSON.parse(File.read("package.json"))
646
- all_deps = package_json["dependencies"]&.merge(package_json["devDependencies"] || {}) || {}
696
+ all_deps = (package_json["dependencies"] || {}).merge(package_json["devDependencies"] || {})
647
697
 
648
698
  webpack_version = all_deps["webpack"]
649
699
  add_info("📦 Webpack version: #{webpack_version}") if webpack_version
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactOnRails
4
- VERSION = "16.4.0.rc.7"
4
+ VERSION = "16.4.0.rc.8"
5
5
  end
@@ -1,19 +1,278 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "date"
3
4
  require "English"
4
5
  require "bundler"
6
+ require "open3"
5
7
  require_relative "task_helpers"
6
8
 
7
9
  CLAUDE_CODE_TIP = <<~TIP
8
10
  ┌─────────────────────────────────────────────────────────────────────────────┐
9
- │ TIP: This task only adds version headers and links, not changelog entries.
10
- │ For full automation, run /update-changelog in Claude Code.
11
+ │ TIP: This task adds version headers and links, not changelog entry text.
12
+ │ For full commit analysis + entry writing, run /update-changelog in Claude.
11
13
  │ │
12
14
  │ After running this task, manually add entries under the new header: │
13
15
  │ #### Fixed / #### Added / #### Changed / etc. │
14
16
  └─────────────────────────────────────────────────────────────────────────────┘
15
17
  TIP
16
18
 
19
+ def monorepo_root_for_changelog
20
+ File.expand_path("../..", __dir__)
21
+ end
22
+
23
+ def prerelease_version?(version)
24
+ version.to_s.match?(/\.(test|beta|alpha|rc|pre)\./i)
25
+ end
26
+
27
+ def normalize_version_string(version_or_tag)
28
+ version = version_or_tag.to_s.strip
29
+ version = version.delete_prefix("v")
30
+ version = version.sub(/-(test|beta|alpha|rc|pre)\./i, '.\1.')
31
+
32
+ unless version.match?(/\A\d+\.\d+\.\d+(\.(test|beta|alpha|rc|pre)\.\d+)?\z/i)
33
+ abort "Failed to parse version from #{version_or_tag.inspect}. Expected format like 16.4.0 or 16.4.0.rc.1."
34
+ end
35
+
36
+ version.downcase
37
+ end
38
+
39
+ def parse_release_tag_to_version(tag)
40
+ version_pattern = /\d+\.\d+\.\d+(?:\.(?:test|beta|alpha|rc|pre)\.\d+)?|\d+\.\d+\.\d+-(?:test|beta|alpha|rc|pre)\.\d+/
41
+ tag_match = tag.to_s.strip.match(/\Av(?<version>#{version_pattern})\z/i)
42
+ return nil unless tag_match
43
+
44
+ normalize_version_string(tag_match[:version])
45
+ rescue SystemExit
46
+ nil
47
+ end
48
+
49
+ def fetch_git_tags!(monorepo_root)
50
+ remotes_output, remotes_status = Open3.capture2e("git", "-C", monorepo_root, "remote")
51
+ abort "Failed to list git remotes.\n#{remotes_output}" unless remotes_status.success?
52
+
53
+ remote_names = remotes_output.lines.map(&:strip).reject(&:empty?)
54
+ return if remote_names.empty?
55
+
56
+ remote_name = remote_names.include?("origin") ? "origin" : remote_names.first
57
+ fetch_output, fetch_status = Open3.capture2e("git", "-C", monorepo_root, "fetch", remote_name, "--tags", "--quiet")
58
+ abort "Failed to fetch git tags from #{remote_name}.\n#{fetch_output}" unless fetch_status.success?
59
+ end
60
+
61
+ def tag_versions(monorepo_root)
62
+ tags_output, status = Open3.capture2e("git", "-C", monorepo_root, "tag", "-l", "v*")
63
+ abort "Failed to list git tags.\n#{tags_output}" unless status.success?
64
+
65
+ tags_output.lines.map(&:strip).filter_map { |tag| parse_release_tag_to_version(tag) }.uniq
66
+ end
67
+
68
+ def stable_tag_versions(monorepo_root)
69
+ tag_versions(monorepo_root).reject { |version| prerelease_version?(version) }
70
+ end
71
+
72
+ def latest_stable_tag_version(monorepo_root)
73
+ versions = stable_tag_versions(monorepo_root)
74
+ abort "Failed to compute latest stable tag: no stable v* tags found." if versions.empty?
75
+
76
+ versions.max_by { |version| Gem::Version.new(version) }
77
+ end
78
+
79
+ def extract_unreleased_section(changelog)
80
+ lines = changelog.lines
81
+ start_index = lines.index { |line| line.start_with?("### [Unreleased]") }
82
+ abort "Failed to find '### [Unreleased]' in CHANGELOG.md" unless start_index
83
+
84
+ end_index = ((start_index + 1)...lines.length).find { |idx| lines[idx].start_with?("### [") } || lines.length
85
+ lines[start_index...end_index].join
86
+ end
87
+
88
+ def inferred_bump_type_from_unreleased(changelog)
89
+ section = extract_unreleased_section(changelog)
90
+ return :major if section.match?(/^####\s+(?:⚠️\s*)?Breaking(?:\s+Changes?)?\b/i)
91
+ return :minor if section.match?(/^####\s+(Added|New\s+Features?|Features?|Enhancements?)\b/i)
92
+ return :patch if section.match?(/^####\s+(Fixed|Fixes|Bug\s+Fixes?|Security|Improved|Changed|Deprecated|Removed)\b/i)
93
+
94
+ :patch
95
+ end
96
+
97
+ def bump_stable_version(version, bump_type)
98
+ match = version.match(/\A(\d+)\.(\d+)\.(\d+)\z/)
99
+ abort "Failed to bump version: stable version #{version.inspect} is invalid." unless match
100
+
101
+ major = match[1].to_i
102
+ minor = match[2].to_i
103
+ patch = match[3].to_i
104
+
105
+ case bump_type
106
+ when :major
107
+ "#{major + 1}.0.0"
108
+ when :minor
109
+ "#{major}.#{minor + 1}.0"
110
+ else
111
+ "#{major}.#{minor}.#{patch + 1}"
112
+ end
113
+ end
114
+
115
+ def prerelease_indices_from_tags(monorepo_root, base_version, channel)
116
+ tags_output, status = Open3.capture2e("git", "-C", monorepo_root, "tag", "-l", "v#{base_version}*")
117
+ abort "Failed to list prerelease tags.\n#{tags_output}" unless status.success?
118
+
119
+ tags_output.lines.map(&:strip).filter_map do |tag|
120
+ normalized_version = parse_release_tag_to_version(tag)
121
+ match = normalized_version&.match(/\A#{Regexp.escape(base_version)}\.#{channel}\.(\d+)\z/i)
122
+ match&.captures&.first&.to_i
123
+ end
124
+ end
125
+
126
+ def prerelease_indices_from_changelog(changelog, base_version, channel)
127
+ changelog.scan(/^### \[#{Regexp.escape(base_version)}\.#{channel}\.(\d+)\]/i).flatten.map(&:to_i)
128
+ end
129
+
130
+ def parse_changelog_sections(changelog)
131
+ lines = changelog.lines
132
+ headers = []
133
+ lines.each_with_index do |line, index|
134
+ match = line.match(/^### \[([^\]]+)\].*$/)
135
+ headers << { index: index, version: match[1], header: line } if match
136
+ end
137
+
138
+ return { prefix: changelog, sections: [] } if headers.empty?
139
+
140
+ prefix = lines[0...headers.first[:index]].join
141
+ sections = headers.each_with_index.map do |header, section_index|
142
+ section_end = if section_index + 1 < headers.length
143
+ headers[section_index + 1][:index]
144
+ else
145
+ lines.length
146
+ end
147
+
148
+ {
149
+ version: header[:version],
150
+ header: header[:header],
151
+ body: lines[(header[:index] + 1)...section_end].join
152
+ }
153
+ end
154
+
155
+ { prefix: prefix, sections: sections }
156
+ end
157
+
158
+ def render_changelog_sections(prefix, sections)
159
+ "#{prefix}#{sections.map { |section| "#{section[:header]}#{section[:body]}" }.join}"
160
+ end
161
+
162
+ def changelog_versions(changelog)
163
+ parse_changelog_sections(changelog)[:sections]
164
+ .map { |section| section[:version] }
165
+ .reject { |version| version == "Unreleased" }
166
+ .map { |version| normalize_version_string(version) }
167
+ end
168
+
169
+ def prerelease_base_version(version)
170
+ version.to_s.sub(/\.(test|beta|alpha|rc|pre)\.\d+\z/i, "")
171
+ end
172
+
173
+ def active_prerelease_base_version(monorepo_root, changelog)
174
+ latest_stable = latest_stable_tag_version(monorepo_root)
175
+ prerelease_bases = (tag_versions(monorepo_root) + changelog_versions(changelog))
176
+ .uniq
177
+ .select { |version| prerelease_version?(version) }
178
+ .map { |version| prerelease_base_version(version) }
179
+ .select do |base_version|
180
+ Gem::Version.new(base_version) > Gem::Version.new(latest_stable)
181
+ end
182
+ .uniq
183
+
184
+ prerelease_bases.max_by { |base_version| Gem::Version.new(base_version) }
185
+ end
186
+
187
+ def collapse_prerelease_series(changelog, base_version)
188
+ %w[test beta alpha rc pre].reduce(changelog) do |current_changelog, channel|
189
+ collapse_prerelease_sections(current_changelog, base_version, channel)
190
+ end
191
+ end
192
+
193
+ def prepare_changelog_for_auto_version(changelog, monorepo_root)
194
+ active_base_version = active_prerelease_base_version(monorepo_root, changelog)
195
+ return changelog unless active_base_version
196
+
197
+ collapse_prerelease_series(changelog, active_base_version)
198
+ end
199
+
200
+ def changelog_section_blocks(section_body)
201
+ block_lines = []
202
+ blocks = []
203
+
204
+ section_body.lines.each do |line|
205
+ normalized_line = line.rstrip
206
+ if normalized_line.match?(/^####+\s+/) && !block_lines.empty?
207
+ blocks << normalize_changelog_block(block_lines)
208
+ block_lines = [normalized_line]
209
+ else
210
+ block_lines << normalized_line
211
+ end
212
+ end
213
+
214
+ blocks << normalize_changelog_block(block_lines) unless block_lines.empty?
215
+ blocks.reject(&:empty?)
216
+ end
217
+
218
+ def normalize_changelog_block(lines)
219
+ normalized_lines = lines.map(&:rstrip)
220
+ normalized_lines.shift while normalized_lines.first == ""
221
+ normalized_lines.pop while normalized_lines.last == ""
222
+ normalized_lines.join("\n")
223
+ end
224
+
225
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
226
+ def collapse_prerelease_sections(changelog, base_version, channel)
227
+ parsed = parse_changelog_sections(changelog)
228
+ sections = parsed[:sections]
229
+ unreleased_section = sections.find { |section| section[:version] == "Unreleased" }
230
+ return changelog unless unreleased_section
231
+
232
+ target_regex = /\A#{Regexp.escape(base_version)}\.#{channel}\.\d+\z/i
233
+ matching_sections = sections.select { |section| section[:version].match?(target_regex) }
234
+ return changelog if matching_sections.empty?
235
+
236
+ merged_body = matching_sections
237
+ .flat_map { |section| changelog_section_blocks(section[:body]) }
238
+ .uniq
239
+ .join("\n\n")
240
+ .strip
241
+ sections.reject! { |section| section[:version].match?(target_regex) }
242
+
243
+ unless merged_body.empty?
244
+ unreleased_body = unreleased_section[:body].rstrip
245
+ unreleased_section[:body] = if unreleased_body.empty?
246
+ "#{merged_body}\n"
247
+ else
248
+ "#{unreleased_body}\n\n#{merged_body}\n"
249
+ end
250
+ end
251
+
252
+ render_changelog_sections(parsed[:prefix], sections)
253
+ end
254
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
255
+
256
+ def compute_auto_version(changelog, mode, monorepo_root, changelog_for_bump: changelog)
257
+ bump_type = inferred_bump_type_from_unreleased(changelog_for_bump)
258
+ latest_stable = latest_stable_tag_version(monorepo_root)
259
+ base_version = bump_stable_version(latest_stable, bump_type)
260
+
261
+ return base_version if mode == "release"
262
+
263
+ indices = prerelease_indices_from_tags(monorepo_root, base_version, mode) +
264
+ prerelease_indices_from_changelog(changelog, base_version, mode)
265
+ next_index = indices.empty? ? 0 : indices.max + 1
266
+ "#{base_version}.#{mode}.#{next_index}"
267
+ end
268
+
269
+ def fetch_git_tag_date(monorepo_root, git_tag)
270
+ output, status = Open3.capture2e("git", "-C", monorepo_root, "show", "-s", "--format=%cs", git_tag)
271
+ return nil unless status.success?
272
+
273
+ output.split("\n").last&.strip
274
+ end
275
+
17
276
  # Update the compare links at the bottom of the changelog
18
277
  # version: version string without 'v' prefix (e.g., "16.2.0.beta.20")
19
278
  # anchor: markdown anchor (e.g., "[16.2.0.beta.20]")
@@ -39,26 +298,64 @@ def insert_version_header(changelog, anchor, tag_date)
39
298
  false
40
299
  end
41
300
 
42
- desc "Updates CHANGELOG.md inserting headers for the new version (headers only, not content).
43
- Argument: Git tag. Defaults to the latest tag.
301
+ desc "Updates CHANGELOG.md by inserting a version header and compare links.
302
+ Argument: Mode (`release`, `rc`, `beta`) or explicit git tag/version.
303
+
304
+ Modes:
305
+ - release: auto-compute next stable version from Unreleased section headings
306
+ - rc: auto-compute next RC version and collapse prior RC sections of same base version
307
+ - beta: auto-compute next beta version and collapse prior beta sections of same base version
308
+
309
+ Explicit argument examples:
310
+ - v16.4.0.rc.6
311
+ - 16.4.0.rc.6
312
+
313
+ No argument: use latest git tag.
44
314
  TIP: Use /update-changelog in Claude Code for full automation."
45
315
 
46
- task :update_changelog, %i[tag] do |_, args|
316
+ # rubocop:disable Metrics/BlockLength
317
+ task :update_changelog, %i[mode_or_tag] do |_, args|
47
318
  puts CLAUDE_CODE_TIP
48
319
 
49
- # Git tags use 'v' prefix (e.g., v16.2.0), but CHANGELOG uses versions without it
50
- git_tag = args[:tag] || `git describe --tags --abbrev=0`.strip
51
- changelog_version = git_tag.delete_prefix("v")
52
- anchor = "[#{changelog_version}]"
53
- changelog = File.read("CHANGELOG.md")
320
+ monorepo_root = monorepo_root_for_changelog
321
+ changelog_path = File.join(monorepo_root, "CHANGELOG.md")
322
+ changelog = File.read(changelog_path)
323
+ input = args[:mode_or_tag].to_s.strip
324
+ auto_mode = %w[release rc beta].find { |mode| mode == input.downcase }
54
325
 
55
- if changelog.include?(anchor)
56
- puts "Tag #{git_tag} is already documented in CHANGELOG.md"
57
- next
326
+ if auto_mode
327
+ fetch_git_tags!(monorepo_root)
328
+ prepared_changelog = prepare_changelog_for_auto_version(changelog, monorepo_root)
329
+ changelog_version = compute_auto_version(
330
+ changelog,
331
+ auto_mode,
332
+ monorepo_root,
333
+ changelog_for_bump: prepared_changelog
334
+ )
335
+ changelog = prepared_changelog
336
+ tag_date = Date.today.strftime("%Y-%m-%d")
337
+ puts "Auto-computed #{auto_mode} version: #{changelog_version}"
338
+ else
339
+ git_tag = if input.empty?
340
+ git_output, git_status = Open3.capture2e("git", "-C", monorepo_root, "describe", "--tags", "--abbrev=0")
341
+ abort "Failed to get latest git tag.\n#{git_output}" unless git_status.success?
342
+ git_output.strip
343
+ else
344
+ input
345
+ end
346
+
347
+ changelog_version = normalize_version_string(git_tag)
348
+ tag_candidates = [git_tag, git_tag.start_with?("v") ? git_tag : "v#{git_tag}", "v#{changelog_version}"].uniq
349
+ tag_date = tag_candidates.filter_map { |candidate| fetch_git_tag_date(monorepo_root, candidate) }.first ||
350
+ Date.today.strftime("%Y-%m-%d")
58
351
  end
59
352
 
60
- tag_date = `git show -s --format=%cs #{git_tag} 2>&1`.split("\n").last&.strip
61
- abort("Failed to find tag #{git_tag}") unless $CHILD_STATUS.success? && tag_date
353
+ anchor = "[#{changelog_version}]"
354
+ header = "### #{anchor}"
355
+ if changelog.include?(header)
356
+ puts "Version #{changelog_version} is already documented in CHANGELOG.md"
357
+ next
358
+ end
62
359
 
63
360
  unless insert_version_header(changelog, anchor, tag_date)
64
361
  abort("Failed to insert version header: could not find '### [Unreleased]' " \
@@ -67,7 +364,8 @@ task :update_changelog, %i[tag] do |_, args|
67
364
 
68
365
  update_changelog_links(changelog, changelog_version, anchor)
69
366
 
70
- File.write("CHANGELOG.md", changelog)
71
- puts "Updated CHANGELOG.md with version header for #{git_tag}"
367
+ File.write(changelog_path, changelog)
368
+ puts "Updated CHANGELOG.md with version header for #{changelog_version}"
72
369
  puts "NOTE: You still need to write the changelog entries manually."
73
370
  end
371
+ # rubocop:enable Metrics/BlockLength
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: react_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 16.4.0.rc.7
4
+ version: 16.4.0.rc.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Justin Gordon
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-03-09 00:00:00.000000000 Z
11
+ date: 2026-03-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: addressable