react_on_rails 16.6.0 → 16.7.0.rc.0

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +1 -0
  3. data/Gemfile.development_dependencies +2 -2
  4. data/Gemfile.lock +2 -14
  5. data/Rakefile +0 -6
  6. data/Steepfile +4 -0
  7. data/lib/generators/react_on_rails/base_generator.rb +4 -4
  8. data/lib/generators/react_on_rails/demo_page_config.rb +3 -3
  9. data/lib/generators/react_on_rails/dev_tests_generator.rb +1 -1
  10. data/lib/generators/react_on_rails/generator_helper.rb +6 -65
  11. data/lib/generators/react_on_rails/generator_messages/ci_section.rb +42 -0
  12. data/lib/generators/react_on_rails/generator_messages/package_manager_detection.rb +194 -0
  13. data/lib/generators/react_on_rails/generator_messages/shakapacker_status_section.rb +61 -0
  14. data/lib/generators/react_on_rails/generator_messages.rb +22 -79
  15. data/lib/generators/react_on_rails/install_generator.rb +243 -28
  16. data/lib/generators/react_on_rails/js_dependency_manager.rb +7 -4
  17. data/lib/generators/react_on_rails/pro/USAGE +1 -1
  18. data/lib/generators/react_on_rails/pro_generator.rb +206 -183
  19. data/lib/generators/react_on_rails/pro_setup.rb +102 -26
  20. data/lib/generators/react_on_rails/react_with_redux_generator.rb +3 -2
  21. data/lib/generators/react_on_rails/templates/base/base/.env.example +25 -0
  22. data/lib/generators/react_on_rails/templates/base/base/.github/workflows/ci.yml.tt +86 -0
  23. data/lib/generators/react_on_rails/templates/base/base/Procfile.dev +4 -3
  24. data/lib/generators/react_on_rails/templates/base/base/babel.config.js.tt +1 -1
  25. data/lib/generators/react_on_rails/templates/base/base/bin/switch-bundler +2 -2
  26. data/lib/generators/react_on_rails/templates/base/base/config/webpack/ServerClientOrBoth.js.tt +1 -1
  27. data/lib/generators/react_on_rails/templates/base/base/config/webpack/clientWebpackConfig.js.tt +1 -1
  28. data/lib/generators/react_on_rails/templates/base/base/config/webpack/commonWebpackConfig.js.tt +2 -2
  29. data/lib/generators/react_on_rails/templates/base/base/config/webpack/development.js.tt +1 -1
  30. data/lib/generators/react_on_rails/templates/base/base/config/webpack/production.js.tt +1 -1
  31. data/lib/generators/react_on_rails/templates/base/base/config/webpack/serverWebpackConfig.js.tt +6 -5
  32. data/lib/generators/react_on_rails/templates/base/base/config/webpack/test.js.tt +1 -1
  33. data/lib/generators/react_on_rails/templates/pro/base/config/initializers/react_on_rails_pro.rb.tt +1 -1
  34. data/lib/generators/react_on_rails/templates/pro/base/{client → renderer}/node-renderer.js +1 -0
  35. data/lib/react_on_rails/config_path_resolver.rb +101 -4
  36. data/lib/react_on_rails/configuration.rb +22 -0
  37. data/lib/react_on_rails/dev/file_manager.rb +135 -8
  38. data/lib/react_on_rails/dev/port_selector.rb +259 -7
  39. data/lib/react_on_rails/dev/process_manager.rb +29 -2
  40. data/lib/react_on_rails/dev/server_manager.rb +607 -39
  41. data/lib/react_on_rails/doctor.rb +513 -45
  42. data/lib/react_on_rails/helper.rb +3 -11
  43. data/lib/react_on_rails/js_code_builder.rb +66 -0
  44. data/lib/react_on_rails/length_prefixed_parser.rb +142 -0
  45. data/lib/react_on_rails/packs_generator.rb +65 -12
  46. data/lib/react_on_rails/pro_migration.rb +175 -0
  47. data/lib/react_on_rails/render_request.rb +74 -0
  48. data/lib/react_on_rails/rendering_strategy/exec_js_strategy.rb +29 -0
  49. data/lib/react_on_rails/rendering_strategy.rb +44 -0
  50. data/lib/react_on_rails/server_rendering_pool/ruby_embedded_java_script.rb +33 -22
  51. data/lib/react_on_rails/system_checker.rb +44 -23
  52. data/lib/react_on_rails/utils.rb +5 -0
  53. data/lib/react_on_rails/version.rb +1 -1
  54. data/lib/react_on_rails.rb +3 -0
  55. data/rakelib/run_rspec.rake +0 -5
  56. data/rakelib/shakapacker_examples.rake +66 -23
  57. data/react_on_rails.gemspec +18 -8
  58. data/sig/react_on_rails/js_code_builder.rbs +11 -0
  59. data/sig/react_on_rails/render_request.rbs +28 -0
  60. data/sig/react_on_rails/rendering_strategy/exec_js_strategy.rbs +11 -0
  61. data/sig/react_on_rails/rendering_strategy.rbs +7 -0
  62. data/sig/react_on_rails.rbs +6 -0
  63. metadata +31 -10
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bd57c5ba417fed762b468a17579ea7bdf04bb314730eb62e9b2ae210fd873c9c
4
- data.tar.gz: a860a8fafb5f17aaf1bb9f07d519a3d73bbc6f3b997b44d7398c7fce7e4c45ab
3
+ metadata.gz: daa4f99f9713669a2de0a7b58659c117b757f91f55674609633c6d8485f0b2d1
4
+ data.tar.gz: 89f475046a66dcfcf3e21413a7067110d2e9afe36c6e2e4436327afe0b89c68c
5
5
  SHA512:
6
- metadata.gz: 36ac38e9d74615e2970df360ce082bbfc625624347deaf0ddddf4756c21c112a76572795652ccd94f8f0cc05e8bbf51b6499a68a36c61a0e32eac06b17268721
7
- data.tar.gz: 6a084e79ce8b4b8b5f9328d3243d71d740a2f34291f07c57fc56e33985d75242015171fc7c15b74a36545dc40263e603ece9cb0d4d722e4d624060fb56c14726
6
+ metadata.gz: 2aa4e2dba93c13c2a0de444d56991c9c1819aef7c47108a35a3e88e11a363e39ebe3c0aa96958dd24bc27d9a5a233f5a9e881a0d5fefdb608946f26206532709
7
+ data.tar.gz: 4ae3e039a9913c8829858123b74477aff6adc9f13b6febd2ef500796a3a71d77da29a934ecddc31573b3141de495a84def5aed0f84c23545035d0ed5aaa75821
data/.rubocop.yml CHANGED
@@ -35,6 +35,7 @@ Metrics/ClassLength:
35
35
  Exclude:
36
36
  - 'lib/generators/react_on_rails/base_generator.rb' # Generator complexity justified
37
37
  - 'lib/react_on_rails/dev/server_manager.rb' # Dev tool with comprehensive help system
38
+ - 'lib/react_on_rails/dev/port_selector.rb' # Base-port mode plus dual-stack probing keep this above the 150-line threshold
38
39
 
39
40
  Metrics/MethodLength:
40
41
  Exclude:
@@ -31,7 +31,7 @@ group :development, :test do
31
31
  gem "listen"
32
32
  gem "debug"
33
33
  gem "pry"
34
- gem "pry-byebug"
34
+ gem "pry-byebug", require: false
35
35
  gem "pry-doc"
36
36
  gem "pry-rails"
37
37
  gem "pry-rescue"
@@ -48,7 +48,7 @@ end
48
48
  group :test do
49
49
  gem "capybara", "~> 3.40"
50
50
  gem "capybara-screenshot"
51
- gem "coveralls", require: false
51
+ gem "simplecov", "~> 0.16.1", require: false
52
52
  gem "cypress-on-rails", "~> 1.19"
53
53
  gem "equivalent-xml"
54
54
  gem "generator_spec"
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- react_on_rails (16.6.0)
4
+ react_on_rails (16.7.0.rc.0)
5
5
  addressable
6
6
  connection_pool
7
7
  execjs (~> 2.5)
@@ -119,12 +119,6 @@ GEM
119
119
  coderay (1.1.3)
120
120
  concurrent-ruby (1.3.6)
121
121
  connection_pool (3.0.2)
122
- coveralls (0.8.23)
123
- json (>= 1.8, < 3)
124
- simplecov (~> 0.16.1)
125
- term-ansicolor (~> 1.3)
126
- thor (>= 0.19.4, < 2.0)
127
- tins (~> 1.6)
128
122
  crass (1.0.6)
129
123
  csv (3.3.5)
130
124
  cypress-on-rails (1.20.0)
@@ -401,17 +395,11 @@ GEM
401
395
  uri (>= 0.12.0)
402
396
  stringio (3.2.0)
403
397
  strscan (3.1.0)
404
- sync (0.5.0)
405
- term-ansicolor (1.8.0)
406
- tins (~> 1.0)
407
398
  terminal-table (3.0.2)
408
399
  unicode-display_width (>= 1.1.1, < 3)
409
400
  thor (1.5.0)
410
401
  tilt (2.3.0)
411
402
  timeout (0.6.0)
412
- tins (1.33.0)
413
- bigdecimal
414
- sync
415
403
  tsort (0.2.0)
416
404
  turbo-rails (2.0.20)
417
405
  actionpack (>= 7.1.0)
@@ -448,7 +436,6 @@ DEPENDENCIES
448
436
  bootsnap
449
437
  capybara (~> 3.40)
450
438
  capybara-screenshot
451
- coveralls
452
439
  cypress-on-rails (~> 1.19)
453
440
  debug
454
441
  equivalent-xml
@@ -481,6 +468,7 @@ DEPENDENCIES
481
468
  sdoc
482
469
  selenium-webdriver (= 4.9.0)
483
470
  shakapacker (= 9.6.1)
471
+ simplecov (~> 0.16.1)
484
472
  spring (~> 4.0)
485
473
  sprockets (~> 4.0)
486
474
  sqlite3 (~> 1.6)
data/Rakefile CHANGED
@@ -6,12 +6,6 @@
6
6
  tasks = %w[lint run_rspec]
7
7
  prepare_for_ci = %w[node_package dummy_apps]
8
8
 
9
- if ENV["USE_COVERALLS"] == "TRUE"
10
- require "coveralls/rake/task"
11
- Coveralls::RakeTask.new
12
- tasks << "coveralls:push"
13
- end
14
-
15
9
  desc "Run all tests and linting"
16
10
  task default: tasks
17
11
 
data/Steepfile CHANGED
@@ -36,7 +36,11 @@ target :lib do
36
36
  check "lib/react_on_rails/dev/service_checker.rb"
37
37
  check "lib/react_on_rails/git_utils.rb"
38
38
  check "lib/react_on_rails/helper.rb"
39
+ check "lib/react_on_rails/js_code_builder.rb"
39
40
  check "lib/react_on_rails/packer_utils.rb"
41
+ check "lib/react_on_rails/render_request.rb"
42
+ check "lib/react_on_rails/rendering_strategy.rb"
43
+ check "lib/react_on_rails/rendering_strategy/exec_js_strategy.rb"
40
44
  check "lib/react_on_rails/server_rendering_pool.rb"
41
45
  check "lib/react_on_rails/test_helper.rb"
42
46
  check "lib/react_on_rails/utils.rb"
@@ -418,7 +418,7 @@ module ReactOnRails
418
418
 
419
419
  if use_pro?
420
420
  hints << {
421
- path: "client/node-renderer.js",
421
+ path: "renderer/node-renderer.js",
422
422
  description: "Node renderer entrypoint used for Pro SSR and RSC."
423
423
  }
424
424
  end
@@ -445,7 +445,7 @@ module ReactOnRails
445
445
  },
446
446
  {
447
447
  title: "Marketplace RSC demo",
448
- url: "https://github.com/shakacode/react-server-components-marketplace-demo",
448
+ url: "https://github.com/shakacode/react-on-rails-demo-marketplace-rsc",
449
449
  description: "Study a larger app comparing traditional SSR, client boundaries, and streamed RSC."
450
450
  }
451
451
  ]
@@ -524,7 +524,7 @@ module ReactOnRails
524
524
  },
525
525
  {
526
526
  label: "Marketplace demo",
527
- url: "https://github.com/shakacode/react-server-components-marketplace-demo"
527
+ url: "https://github.com/shakacode/react-on-rails-demo-marketplace-rsc"
528
528
  }
529
529
  ]
530
530
 
@@ -666,7 +666,7 @@ module ReactOnRails
666
666
  end
667
667
 
668
668
  def home_page_pro_note_for_oss_app
669
- "You can evaluate React on Rails Pro without a license while deciding whether it fits your app."
669
+ "Review the Pro docs and upgrade guide first, then enable it with the appropriate license when you're ready."
670
670
  end
671
671
 
672
672
  def preferred_rspec_helper_file
@@ -145,7 +145,7 @@ module ReactOnRails
145
145
  },
146
146
  {
147
147
  label: "Marketplace RSC demo",
148
- url: "https://github.com/shakacode/react-server-components-marketplace-demo"
148
+ url: "https://github.com/shakacode/react-on-rails-demo-marketplace-rsc"
149
149
  }
150
150
  ]
151
151
  end
@@ -182,7 +182,7 @@ module ReactOnRails
182
182
  description: "Rails view that calls stream_react_component."
183
183
  },
184
184
  {
185
- path: "client/node-renderer.js",
185
+ path: "renderer/node-renderer.js",
186
186
  description: "Node renderer entrypoint used by the Pro SSR and RSC stack."
187
187
  }
188
188
  ]
@@ -216,7 +216,7 @@ module ReactOnRails
216
216
  },
217
217
  {
218
218
  label: "Marketplace RSC demo",
219
- url: "https://github.com/shakacode/react-server-components-marketplace-demo"
219
+ url: "https://github.com/shakacode/react-on-rails-demo-marketplace-rsc"
220
220
  }
221
221
  ]
222
222
  end
@@ -41,7 +41,7 @@ module ReactOnRails
41
41
  gem("rspec-rails", group: :test)
42
42
  # NOTE: chromedriver-helper was deprecated in 2019. Modern selenium-webdriver (4.x)
43
43
  # and GitHub Actions have built-in driver management, so no driver helper is needed.
44
- gem("coveralls", require: false)
44
+ gem("simplecov", require: false, group: :test)
45
45
  end
46
46
 
47
47
  def replace_prerender_if_server_rendering
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "json"
4
4
 
5
- # rubocop:disable Metrics/ModuleLength
6
5
  module GeneratorHelper
7
6
  def package_json
8
7
  # Lazy load package_json gem only when actually needed for dependency management
@@ -43,17 +42,6 @@ module GeneratorHelper
43
42
  end
44
43
  end
45
44
 
46
- # Takes a relative path from the destination root, such as `.gitignore` or `app/assets/javascripts/application.js`
47
- def dest_file_exists?(file)
48
- dest_file = File.join(destination_root, file)
49
- File.exist?(dest_file) ? dest_file : nil
50
- end
51
-
52
- def dest_dir_exists?(dir)
53
- dest_dir = File.join(destination_root, dir)
54
- Dir.exist?(dest_dir) ? dest_dir : nil
55
- end
56
-
57
45
  # Detect whether config/routes.rb defines any non-commented root route.
58
46
  #
59
47
  # @param routes_path [String] absolute path to routes.rb
@@ -66,45 +54,6 @@ module GeneratorHelper
66
54
  end
67
55
  end
68
56
 
69
- def setup_file_error(file, data)
70
- <<~MSG
71
- #{file} was not found.
72
- Please add the following content to your #{file} file:
73
- #{data}
74
- MSG
75
- end
76
-
77
- def empty_directory_with_keep_file(destination, config = {})
78
- empty_directory(destination, config)
79
- keep_file(destination)
80
- end
81
-
82
- def keep_file(destination)
83
- create_file("#{destination}/.keep") unless options[:skip_keeps]
84
- end
85
-
86
- # As opposed to Rails::Generators::Testing.create_link, which creates a link pointing to
87
- # source_root, this symlinks a file in destination_root to a file also in
88
- # destination_root.
89
- def symlink_dest_file_to_dest_file(target, link)
90
- target_pathname = Pathname.new(File.join(destination_root, target))
91
- link_pathname = Pathname.new(File.join(destination_root, link))
92
-
93
- link_directory = link_pathname.dirname
94
- link_basename = link_pathname.basename
95
- target_relative_path = target_pathname.relative_path_from(link_directory)
96
-
97
- `cd #{link_directory} && ln -s #{target_relative_path} #{link_basename}`
98
- end
99
-
100
- def copy_file_and_missing_parent_directories(source_file, destination_file = nil)
101
- destination_file ||= source_file
102
- destination_path = Pathname.new(destination_file)
103
- parent_directories = destination_path.dirname
104
- empty_directory(parent_directories) unless dest_dir_exists?(parent_directories)
105
- copy_file source_file, destination_file
106
- end
107
-
108
57
  def add_documentation_reference(message, source)
109
58
  "#{message} \n#{source}"
110
59
  end
@@ -148,32 +97,25 @@ module GeneratorHelper
148
97
  @pro_gem_installed = Gem.loaded_specs.key?("react_on_rails_pro") || gem_in_lockfile?("react_on_rails_pro")
149
98
  end
150
99
 
100
+ # TODO: CQS smell: mark_pro_gem_installed! makes pro_gem_installed? return true before install. See #3303.
151
101
  def mark_pro_gem_installed!
152
102
  @pro_gem_installed = true
153
103
  end
154
104
 
155
- # Check if first-class RSC Pro mode should be enabled.
156
- # Returns true when --rsc-pro is set, or when users explicitly pass both --rsc and --pro.
157
- #
158
- # @return [Boolean] true if RSC Pro mode semantics should be applied
159
- def use_rsc_pro_mode?
160
- options[:rsc_pro] || (options[:rsc] && options[:pro])
161
- end
162
-
163
105
  # Check if Pro features should be enabled.
164
- # Returns true if --pro, --rsc, or --rsc-pro is set (RSC implies Pro).
106
+ # Returns true if --pro or --rsc is set (RSC implies Pro).
165
107
  #
166
108
  # @return [Boolean] true if Pro setup should be included
167
109
  def use_pro?
168
- options[:pro] || options[:rsc] || options[:rsc_pro]
110
+ options[:pro] || options[:rsc]
169
111
  end
170
112
 
171
- # Check if RSC (React Server Components) should be enabled
172
- # Returns true if --rsc or --rsc-pro is explicitly set
113
+ # Check if RSC (React Server Components) should be enabled.
114
+ # Returns true if --rsc is set.
173
115
  #
174
116
  # @return [Boolean] true if RSC setup should be included
175
117
  def use_rsc?
176
- options[:rsc] || options[:rsc_pro]
118
+ options[:rsc]
177
119
  end
178
120
 
179
121
  # Determine if the project is using rspack as the bundler.
@@ -378,4 +320,3 @@ module GeneratorHelper
378
320
  config.dig("default", "assets_bundler") == "rspack"
379
321
  end
380
322
  end
381
- # rubocop:enable Metrics/ModuleLength
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+
5
+ module GeneratorMessages
6
+ module CiSection
7
+ private
8
+
9
+ def build_ci_section(app_root: Dir.pwd, ci_workflow_generated: false)
10
+ return "" unless ci_workflow_generated || File.exist?(File.join(app_root, ".github/workflows/ci.yml"))
11
+
12
+ # Read package.json once and reuse for both package-manager detection and the
13
+ # build:test script presence check to avoid a second I/O pass.
14
+ package_json = read_package_json(app_root)
15
+ package_manager = detect_package_manager(app_root: app_root, package_json: package_json)
16
+ ci_status = if ci_workflow_generated
17
+ "A GitHub Actions workflow has been generated at .github/workflows/ci.yml."
18
+ else
19
+ "A GitHub Actions workflow is available at .github/workflows/ci.yml."
20
+ end
21
+
22
+ build_test_hint = if package_json&.dig("scripts", "build:test")
23
+ "\n\nOr use the generated package.json script:\n" \
24
+ "#{Rainbow("#{package_manager} run build:test").cyan}"
25
+ else
26
+ ""
27
+ end
28
+
29
+ <<~CI
30
+
31
+
32
+ 🔄 CI / BUILD ORDERING:
33
+ ─────────────────────────────────────────────────────────────────────────
34
+ JavaScript bundles must be built before running Rails tests.
35
+ #{ci_status}
36
+
37
+ To build bundles manually before tests:
38
+ #{Rainbow('RAILS_ENV=test NODE_ENV=test bin/shakapacker').cyan}#{build_test_hint}
39
+ CI
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "react_on_rails/utils"
5
+
6
+ module GeneratorMessages
7
+ # Package-manager detection helpers used by the install generator and the
8
+ # post-install message. Split out of GeneratorMessages to keep that class
9
+ # under Metrics/ClassLength and to group related logic together.
10
+ module PackageManagerDetection
11
+ SUPPORTED_PACKAGE_MANAGERS = %w[npm pnpm yarn bun].freeze
12
+ PACKAGE_JSON_UNSET = Object.new.freeze
13
+ private_constant :PACKAGE_JSON_UNSET
14
+
15
+ # Hash insertion order is the detection priority used by
16
+ # detect_package_manager_from_lockfiles (yarn → pnpm → bun → npm).
17
+ LOCKFILE_CANDIDATES_BY_MANAGER = {
18
+ "yarn" => ["yarn.lock"],
19
+ "pnpm" => ["pnpm-lock.yaml"],
20
+ "bun" => ["bun.lock", "bun.lockb"],
21
+ "npm" => ["package-lock.json"]
22
+ }.freeze
23
+
24
+ # Detects the package manager in priority order:
25
+ # 1. REACT_ON_RAILS_PACKAGE_MANAGER env variable
26
+ # 2. packageManager field in package.json (Corepack standard)
27
+ # 3. Lockfile on disk
28
+ # 4. Falls back to "npm" (Shakapacker 8.x default)
29
+ #
30
+ # Pass app_root: to resolve paths against a specific directory
31
+ # (e.g. destination_root in generators) instead of Dir.pwd.
32
+ # Omit `package_json:` (the default) to read package.json from disk.
33
+ # Pass package_json: <parsed_hash> to reuse an already-parsed package.json and
34
+ # avoid a re-read (callers that also inspect scripts/deps should parse once and
35
+ # pass the hash).
36
+ # Pass package_json: nil when the caller already attempted to read package.json and
37
+ # wants detection to fall through directly to lockfile heuristics.
38
+ def detect_package_manager(app_root: Dir.pwd, package_json: PACKAGE_JSON_UNSET)
39
+ detect_package_manager_with_source(
40
+ app_root: app_root,
41
+ package_json: package_json
42
+ ).first
43
+ end
44
+
45
+ # source is one of :env, :package_json, :lockfile, :default — used to
46
+ # name the originating source when surfacing detection errors.
47
+ #
48
+ # See `detect_package_manager` for the `package_json:` three-way semantics
49
+ # (omitted = read from disk, nil = caller cached absent, Hash = pre-parsed).
50
+ def detect_package_manager_with_source(app_root: Dir.pwd, package_json: PACKAGE_JSON_UNSET)
51
+ env_package_manager = ENV.fetch("REACT_ON_RAILS_PACKAGE_MANAGER", nil)&.strip&.downcase
52
+ return [env_package_manager, :env] if supported_package_manager?(env_package_manager)
53
+
54
+ content = package_json_content(
55
+ app_root: app_root,
56
+ package_json: package_json
57
+ )
58
+ pm_from_json = content ? package_manager_name_from_content(content) : nil
59
+ return [pm_from_json, :package_json] if pm_from_json
60
+
61
+ pm_from_lockfile = detect_package_manager_from_lockfiles(app_root: app_root)
62
+ return [pm_from_lockfile, :lockfile] if pm_from_lockfile
63
+
64
+ ["npm", :default]
65
+ end
66
+
67
+ def lockfile_filename_for(package_manager, app_root: Dir.pwd)
68
+ LOCKFILE_CANDIDATES_BY_MANAGER[package_manager]&.find do |name|
69
+ File.exist?(File.join(app_root, name))
70
+ end
71
+ end
72
+
73
+ # Returns true when package.json declares a top-level `packageManager` field with an
74
+ # npm-style version/range/tag (e.g. `"pnpm@9.0.0"`, `"pnpm@^10.0.0"`, or
75
+ # `"pnpm@latest"`) for the requested `manager`. The CI scaffold treats these as
76
+ # declared so it does not inject a conflicting fallback `version:`. Projects that
77
+ # need reproducible Corepack behavior should prefer an exact version, optionally
78
+ # with a hash (e.g. `"pnpm@9.0.0+sha256.abc"`). A bare name without `@<version>`
79
+ # returns false because `pnpm/action-setup` has no version to resolve from it.
80
+ # Used by the CI scaffold to decide whether `pnpm/action-setup` needs an explicit
81
+ # `version:` key; exact SemVer validation belongs only where a caller needs to
82
+ # extract a reproducible version pin.
83
+ # Pass package_json: <parsed_hash> to reuse an already-parsed package.json and
84
+ # package_json: nil to preserve a cached missing/unreadable read.
85
+ def package_manager_declared?(manager:, app_root: Dir.pwd, package_json: PACKAGE_JSON_UNSET)
86
+ content = package_json_content(
87
+ app_root: app_root,
88
+ package_json: package_json
89
+ )
90
+ return false unless content
91
+
92
+ declared = versioned_package_manager_name_from_content(content)
93
+ return false if declared.nil?
94
+
95
+ declared == manager.to_s.downcase
96
+ end
97
+
98
+ # Used by the CI scaffold so `cache:` / `<pm> install` never reference a lockfile
99
+ # that's not on disk (e.g. `packageManager: pnpm` without `pnpm-lock.yaml`, which
100
+ # breaks `actions/setup-node`'s cache step).
101
+ def lockfile_for_manager?(package_manager, app_root: Dir.pwd)
102
+ !lockfile_filename_for(package_manager, app_root: app_root).nil?
103
+ end
104
+
105
+ def detect_package_manager_from_lockfiles(app_root: Dir.pwd)
106
+ LOCKFILE_CANDIDATES_BY_MANAGER.keys.find do |pm|
107
+ lockfile_for_manager?(pm, app_root: app_root)
108
+ end
109
+ end
110
+
111
+ def supported_package_manager?(package_manager)
112
+ SUPPORTED_PACKAGE_MANAGERS.include?(package_manager)
113
+ end
114
+
115
+ def package_manager_executable_available?(package_manager)
116
+ return false unless supported_package_manager?(package_manager)
117
+
118
+ ReactOnRails::Utils.command_available?(package_manager)
119
+ end
120
+
121
+ # Parses package.json once and returns the hash, or nil if the file is missing
122
+ # or unreadable. Generator code can reuse the same parsed hash across setup,
123
+ # template, and message paths.
124
+ #
125
+ # Intentionally public: install_generator and other generator callers read
126
+ # package.json once and pass the result to detect_package_manager /
127
+ # package_manager_declared? to avoid repeated disk reads.
128
+ # When this returns nil, pass package_json: nil to those helpers to preserve
129
+ # that cached missing/unreadable state.
130
+ #
131
+ # @api public
132
+ def read_package_json(app_root)
133
+ package_json_path = File.join(app_root, "package.json")
134
+ return nil unless File.exist?(package_json_path)
135
+
136
+ JSON.parse(File.read(package_json_path))
137
+ rescue JSON::ParserError, Errno::EACCES, Errno::ENOENT
138
+ nil
139
+ end
140
+
141
+ private
142
+
143
+ # Pipeline internals — external callers should go through `detect_package_manager`
144
+ # (which accepts `package_json:` for the read-once case). Reachable from sibling
145
+ # sub-modules (e.g. CiSection) via `include` without a receiver; tests use `send`.
146
+
147
+ def detect_package_manager_from_package_json(app_root: Dir.pwd)
148
+ content = read_package_json(app_root)
149
+ content ? package_manager_name_from_content(content) : nil
150
+ end
151
+
152
+ def package_json_content(app_root:, package_json:)
153
+ return read_package_json(app_root) if package_json.equal?(PACKAGE_JSON_UNSET)
154
+
155
+ # nil means the caller cached that package.json was absent/unreadable.
156
+ package_json
157
+ end
158
+
159
+ def package_manager_name_from_content(content)
160
+ raw_declared = raw_package_manager_field(content)
161
+ return nil if raw_declared.nil?
162
+
163
+ name = raw_declared.split("@", 2).first&.strip&.downcase
164
+ supported_package_manager?(name) ? name : nil
165
+ end
166
+
167
+ # Sibling of `package_manager_name_from_content` for places that need a resolvable
168
+ # spec, not just a manager name. Range or tag specs such as `"pnpm@^10.0.0"` and
169
+ # `"pnpm@latest"` are non-standard for reproducible Corepack usage, but this check
170
+ # treats them as declared to avoid injecting a conflicting fallback version.
171
+ def versioned_package_manager_name_from_content(content)
172
+ declared = raw_package_manager_field(content)
173
+ return nil if declared.nil?
174
+
175
+ match = declared.match(/\A([^@\s]+)@(?:\S+)\z/)
176
+ return nil unless match
177
+
178
+ name = match[1].downcase
179
+ supported_package_manager?(name) ? name : nil
180
+ end
181
+
182
+ # Single source of truth for reading and normalizing the raw `packageManager`
183
+ # string. Acceptance rules differ between callers (lenient name extraction vs.
184
+ # strict version-required regex), but field-handling concerns (type check,
185
+ # whitespace trim) belong in one place.
186
+ def raw_package_manager_field(content)
187
+ raw = content["packageManager"]
188
+ return nil unless raw.is_a?(String)
189
+
190
+ stripped = raw.strip
191
+ stripped.empty? ? nil : stripped
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rainbow"
4
+
5
+ module GeneratorMessages
6
+ module ShakapackerStatusSection
7
+ private
8
+
9
+ def build_shakapacker_status_section(shakapacker_just_installed: false, app_root: Dir.pwd)
10
+ version_warning = check_shakapacker_version_warning(app_root: app_root)
11
+ if shakapacker_just_installed
12
+ base = <<~SHAKAPACKER
13
+
14
+ 📦 SHAKAPACKER SETUP:
15
+ ─────────────────────────────────────────────────────────────────────────
16
+ #{Rainbow('✓ Added to Gemfile automatically').green}
17
+ #{Rainbow('✓ Installer ran successfully').green}
18
+ #{Rainbow('✓ Webpack integration configured').green}
19
+ SHAKAPACKER
20
+ base.chomp + version_warning
21
+ elsif shakapacker_binstubs_present?(app_root)
22
+ "\n📦 #{Rainbow('Shakapacker already configured ✓').green}#{version_warning}"
23
+ else
24
+ "\n📦 #{Rainbow('Shakapacker setup may be incomplete').yellow}#{version_warning}"
25
+ end
26
+ end
27
+
28
+ def shakapacker_binstubs_present?(app_root)
29
+ File.exist?(File.join(app_root, "bin/shakapacker")) &&
30
+ File.exist?(File.join(app_root, "bin/shakapacker-dev-server"))
31
+ end
32
+
33
+ def check_shakapacker_version_warning(app_root: Dir.pwd)
34
+ gemfile_lock = File.join(app_root, "Gemfile.lock")
35
+ return "" unless File.exist?(gemfile_lock)
36
+
37
+ shakapacker_match = File.read(gemfile_lock).match(/shakapacker \((\d+\.\d+\.\d+)\)/)
38
+ return "" unless shakapacker_match
39
+
40
+ version = shakapacker_match[1]
41
+ if version.split(".").first.to_i < 8
42
+ <<~WARNING
43
+
44
+ ⚠️ #{Rainbow('IMPORTANT: Upgrade Recommended').yellow.bold}
45
+ ─────────────────────────────────────────────────────────────────────────
46
+ You are using Shakapacker #{version}. React on Rails v15+ works best with
47
+ Shakapacker 8.0+ for optimal Hot Module Replacement and build performance.
48
+
49
+ To upgrade: #{Rainbow('bundle update shakapacker').cyan}
50
+
51
+ Learn more: #{Rainbow('https://github.com/shakacode/shakapacker').cyan.underline}
52
+ WARNING
53
+ else
54
+ ""
55
+ end
56
+ rescue StandardError
57
+ # If version detection fails, don't show a warning to avoid noise
58
+ ""
59
+ end
60
+ end
61
+ end