shakapacker 8.0.2 → 9.2.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 (198) hide show
  1. checksums.yaml +4 -4
  2. data/.eslintignore +1 -0
  3. data/.eslintrc.fast.js +40 -0
  4. data/.eslintrc.js +48 -0
  5. data/.github/STATUS.md +1 -0
  6. data/.github/workflows/claude-code-review.yml +54 -0
  7. data/.github/workflows/claude.yml +50 -0
  8. data/.github/workflows/dummy.yml +9 -4
  9. data/.github/workflows/generator.yml +32 -10
  10. data/.github/workflows/node.yml +23 -1
  11. data/.github/workflows/ruby.yml +33 -2
  12. data/.github/workflows/test-bundlers.yml +170 -0
  13. data/.gitignore +20 -0
  14. data/.husky/pre-commit +2 -0
  15. data/.npmignore +56 -0
  16. data/.prettierignore +3 -0
  17. data/.rubocop.yml +1 -0
  18. data/.yalcignore +26 -0
  19. data/CHANGELOG.md +302 -16
  20. data/CLAUDE.md +29 -0
  21. data/CONTRIBUTING.md +138 -20
  22. data/Gemfile.lock +83 -89
  23. data/README.md +343 -105
  24. data/Rakefile +39 -4
  25. data/TODO.md +50 -0
  26. data/TODO_v9.md +87 -0
  27. data/bin/export-bundler-config +11 -0
  28. data/conductor-setup.sh +70 -0
  29. data/conductor.json +7 -0
  30. data/docs/cdn_setup.md +379 -0
  31. data/docs/common-upgrades.md +615 -0
  32. data/docs/css-modules-export-mode.md +512 -0
  33. data/docs/deployment.md +62 -9
  34. data/docs/optional-peer-dependencies.md +198 -0
  35. data/docs/peer-dependencies.md +60 -0
  36. data/docs/react.md +6 -14
  37. data/docs/releasing.md +197 -0
  38. data/docs/rspack.md +190 -0
  39. data/docs/rspack_migration_guide.md +305 -0
  40. data/docs/subresource_integrity.md +54 -0
  41. data/docs/transpiler-migration.md +209 -0
  42. data/docs/transpiler-performance.md +179 -0
  43. data/docs/troubleshooting.md +157 -22
  44. data/docs/typescript-migration.md +379 -0
  45. data/docs/typescript.md +99 -0
  46. data/docs/using_esbuild_loader.md +3 -3
  47. data/docs/using_swc_loader.md +112 -10
  48. data/docs/v6_upgrade.md +10 -0
  49. data/docs/v8_upgrade.md +3 -5
  50. data/docs/v9_upgrade.md +458 -0
  51. data/gemfiles/Gemfile-rails.6.0.x +2 -1
  52. data/gemfiles/Gemfile-rails.6.1.x +1 -1
  53. data/gemfiles/Gemfile-rails.7.0.x +2 -2
  54. data/gemfiles/Gemfile-rails.7.1.x +1 -2
  55. data/gemfiles/Gemfile-rails.7.2.x +11 -0
  56. data/gemfiles/Gemfile-rails.8.0.x +11 -0
  57. data/lib/install/bin/export-bundler-config +11 -0
  58. data/lib/install/bin/shakapacker +4 -6
  59. data/lib/install/bin/shakapacker-dev-server +1 -1
  60. data/lib/install/config/rspack/rspack.config.js +6 -0
  61. data/lib/install/config/rspack/rspack.config.ts +7 -0
  62. data/lib/install/config/shakapacker.yml +25 -5
  63. data/lib/install/config/webpack/webpack.config.ts +7 -0
  64. data/lib/install/package.json +38 -0
  65. data/lib/install/template.rb +194 -44
  66. data/lib/shakapacker/bundler_switcher.rb +329 -0
  67. data/lib/shakapacker/compiler.rb +2 -1
  68. data/lib/shakapacker/compiler_strategy.rb +2 -2
  69. data/lib/shakapacker/configuration.rb +173 -2
  70. data/lib/shakapacker/dev_server_runner.rb +29 -8
  71. data/lib/shakapacker/digest_strategy.rb +2 -1
  72. data/lib/shakapacker/doctor.rb +905 -0
  73. data/lib/shakapacker/helper.rb +64 -16
  74. data/lib/shakapacker/manifest.rb +10 -3
  75. data/lib/shakapacker/mtime_strategy.rb +1 -1
  76. data/lib/shakapacker/railtie.rb +4 -4
  77. data/lib/shakapacker/rspack_runner.rb +19 -0
  78. data/lib/shakapacker/runner.rb +159 -10
  79. data/lib/shakapacker/swc_migrator.rb +384 -0
  80. data/lib/shakapacker/utils/manager.rb +15 -2
  81. data/lib/shakapacker/version.rb +1 -1
  82. data/lib/shakapacker/version_checker.rb +2 -2
  83. data/lib/shakapacker/webpack_runner.rb +6 -43
  84. data/lib/shakapacker.rb +22 -11
  85. data/lib/tasks/shakapacker/doctor.rake +8 -0
  86. data/lib/tasks/shakapacker/export_bundler_config.rake +72 -0
  87. data/lib/tasks/shakapacker/install.rake +12 -2
  88. data/lib/tasks/shakapacker/migrate_to_swc.rake +13 -0
  89. data/lib/tasks/shakapacker/switch_bundler.rake +82 -0
  90. data/lib/tasks/shakapacker.rake +2 -0
  91. data/package/.npmignore +4 -0
  92. data/package/babel/preset.ts +56 -0
  93. data/package/config.ts +175 -0
  94. data/package/configExporter/cli.ts +683 -0
  95. data/package/configExporter/configDocs.ts +102 -0
  96. data/package/configExporter/fileWriter.ts +92 -0
  97. data/package/configExporter/index.ts +5 -0
  98. data/package/configExporter/types.ts +36 -0
  99. data/package/configExporter/yamlSerializer.ts +266 -0
  100. data/package/{dev_server.js → dev_server.ts} +8 -5
  101. data/package/env.ts +92 -0
  102. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +30 -0
  103. data/package/environments/{base.js → base.ts} +56 -60
  104. data/package/environments/development.ts +90 -0
  105. data/package/environments/production.ts +80 -0
  106. data/package/environments/test.ts +53 -0
  107. data/package/environments/types.ts +98 -0
  108. data/package/esbuild/index.ts +42 -0
  109. data/package/index.d.ts +3 -60
  110. data/package/index.ts +55 -0
  111. data/package/loaders.d.ts +28 -0
  112. data/package/optimization/rspack.ts +36 -0
  113. data/package/optimization/webpack.ts +57 -0
  114. data/package/plugins/rspack.ts +103 -0
  115. data/package/plugins/webpack.ts +62 -0
  116. data/package/rspack/index.ts +64 -0
  117. data/package/rules/{babel.js → babel.ts} +2 -2
  118. data/package/rules/{coffee.js → coffee.ts} +1 -1
  119. data/package/rules/css.ts +3 -0
  120. data/package/rules/{erb.js → erb.ts} +1 -1
  121. data/package/rules/esbuild.ts +10 -0
  122. data/package/rules/file.ts +40 -0
  123. data/package/rules/{jscommon.js → jscommon.ts} +4 -4
  124. data/package/rules/{less.js → less.ts} +4 -4
  125. data/package/rules/raw.ts +25 -0
  126. data/package/rules/rspack.ts +176 -0
  127. data/package/rules/{sass.js → sass.ts} +7 -3
  128. data/package/rules/{stylus.js → stylus.ts} +4 -8
  129. data/package/rules/swc.ts +10 -0
  130. data/package/rules/webpack.ts +16 -0
  131. data/package/swc/index.ts +56 -0
  132. data/package/types/README.md +88 -0
  133. data/package/types/index.ts +61 -0
  134. data/package/types.ts +108 -0
  135. data/package/utils/configPath.ts +6 -0
  136. data/package/utils/debug.ts +49 -0
  137. data/package/utils/defaultConfigPath.ts +4 -0
  138. data/package/utils/errorCodes.ts +219 -0
  139. data/package/utils/errorHelpers.ts +143 -0
  140. data/package/utils/getStyleRule.ts +64 -0
  141. data/package/utils/helpers.ts +85 -0
  142. data/package/utils/{inliningCss.js → inliningCss.ts} +3 -3
  143. data/package/utils/pathValidation.ts +139 -0
  144. data/package/utils/requireOrError.ts +15 -0
  145. data/package/utils/snakeToCamelCase.ts +5 -0
  146. data/package/utils/typeGuards.ts +342 -0
  147. data/package/utils/validateDependencies.ts +61 -0
  148. data/package/webpack-types.d.ts +33 -0
  149. data/package/webpackDevServerConfig.ts +117 -0
  150. data/package-lock.json +13047 -0
  151. data/package.json +154 -18
  152. data/scripts/remove-use-strict.js +45 -0
  153. data/scripts/type-check-no-emit.js +27 -0
  154. data/test/helpers.js +1 -1
  155. data/test/package/config.test.js +43 -0
  156. data/test/package/env.test.js +42 -7
  157. data/test/package/environments/base.test.js +5 -1
  158. data/test/package/rules/babel.test.js +16 -0
  159. data/test/package/rules/esbuild.test.js +1 -1
  160. data/test/package/rules/raw.test.js +40 -7
  161. data/test/package/rules/swc.test.js +1 -1
  162. data/test/package/rules/webpack.test.js +35 -0
  163. data/test/package/staging.test.js +4 -3
  164. data/test/package/transpiler-defaults.test.js +127 -0
  165. data/test/peer-dependencies.sh +85 -0
  166. data/test/scripts/remove-use-strict.test.js +125 -0
  167. data/test/typescript/build.test.js +118 -0
  168. data/test/typescript/environments.test.js +107 -0
  169. data/test/typescript/pathValidation.test.js +142 -0
  170. data/test/typescript/securityValidation.test.js +182 -0
  171. data/tools/README.md +124 -0
  172. data/tools/css-modules-v9-codemod.js +179 -0
  173. data/tsconfig.eslint.json +16 -0
  174. data/tsconfig.json +38 -0
  175. data/yarn.lock +4165 -2706
  176. metadata +129 -41
  177. data/package/babel/preset.js +0 -37
  178. data/package/config.js +0 -54
  179. data/package/env.js +0 -48
  180. data/package/environments/development.js +0 -13
  181. data/package/environments/production.js +0 -88
  182. data/package/environments/test.js +0 -3
  183. data/package/esbuild/index.js +0 -40
  184. data/package/index.js +0 -40
  185. data/package/rules/css.js +0 -3
  186. data/package/rules/esbuild.js +0 -10
  187. data/package/rules/file.js +0 -29
  188. data/package/rules/index.js +0 -20
  189. data/package/rules/raw.js +0 -5
  190. data/package/rules/swc.js +0 -10
  191. data/package/swc/index.js +0 -50
  192. data/package/utils/configPath.js +0 -4
  193. data/package/utils/defaultConfigPath.js +0 -2
  194. data/package/utils/getStyleRule.js +0 -40
  195. data/package/utils/helpers.js +0 -58
  196. data/package/utils/snakeToCamelCase.js +0 -5
  197. data/package/webpackDevServerConfig.js +0 -71
  198. data/test/package/rules/index.test.js +0 -16
@@ -0,0 +1,7 @@
1
+ // See the shakacode/shakapacker README and docs directory for advice on customizing your webpackConfig.
2
+ import { generateWebpackConfig } from 'shakapacker'
3
+ import type { Configuration } from 'webpack'
4
+
5
+ const webpackConfig: Configuration = generateWebpackConfig()
6
+
7
+ export default webpackConfig
@@ -0,0 +1,38 @@
1
+ {
2
+ "rspack": {
3
+ "@rspack/cli": "^1.0.0",
4
+ "@rspack/core": "^1.0.0",
5
+ "rspack-manifest-plugin": "^5.0.0"
6
+ },
7
+ "webpack": {
8
+ "mini-css-extract-plugin": "^2.0.0",
9
+ "terser-webpack-plugin": "^5.3.1",
10
+ "webpack": "^5.76.0",
11
+ "webpack-assets-manifest": "^5.0.6 || ^6.0.0",
12
+ "webpack-cli": "^4.9.2 || ^5.0.0 || ^6.0.0",
13
+ "webpack-dev-server": "^4.15.2 || ^5.2.2",
14
+ "webpack-merge": "^5.8.0 || ^6.0.0",
15
+ "webpack-subresource-integrity": "^5.1.0"
16
+ },
17
+ "common": {
18
+ "compression-webpack-plugin": "^9.0.0 || ^10.0.0|| ^11.0.0",
19
+ "css-loader": "^6.0.0 || ^7.0.0",
20
+ "sass-loader": "^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0",
21
+ "style-loader": "^3.0.0 || ^4.0.0"
22
+ },
23
+ "babel": {
24
+ "@babel/core": "^7.17.9",
25
+ "@babel/plugin-transform-runtime": "^7.17.0",
26
+ "@babel/preset-env": "^7.16.11",
27
+ "@babel/runtime": "^7.17.9",
28
+ "babel-loader": "^8.2.4 || ^9.0.0 || ^10.0.0"
29
+ },
30
+ "swc": {
31
+ "@swc/core": "^1.3.0",
32
+ "swc-loader": "^0.2.0"
33
+ },
34
+ "esbuild": {
35
+ "esbuild": "^0.24.0",
36
+ "esbuild-loader": "^4.0.0"
37
+ }
38
+ }
@@ -2,26 +2,72 @@ require "shakapacker/utils/misc"
2
2
  require "shakapacker/utils/manager"
3
3
  require "shakapacker/utils/version_syntax_converter"
4
4
  require "package_json"
5
+ require "yaml"
6
+ require "json"
5
7
 
6
8
  # Install Shakapacker
7
9
 
8
10
  force_option = ENV["FORCE"] ? { force: true } : {}
9
11
 
10
- copy_file "#{__dir__}/config/shakapacker.yml", "config/shakapacker.yml", force_option
11
- remove_file "#{__dir__}/package.json" if force_option[:force]
12
+ # Initialize variables for use throughout the template
13
+ # Using instance variable to avoid method definition issues in Rails templates
14
+ @package_json ||= PackageJson.new
15
+ install_dir = File.expand_path(File.dirname(__FILE__))
12
16
 
13
- say "Copying webpack core config"
14
- directory "#{__dir__}/config/webpack", "config/webpack", force_option
17
+ # Installation strategy:
18
+ # - USE_BABEL_PACKAGES installs both babel AND swc for compatibility
19
+ # - Otherwise install only the specified transpiler
20
+ if ENV["USE_BABEL_PACKAGES"] == "true" || ENV["USE_BABEL_PACKAGES"] == "1"
21
+ @transpiler_to_install = "babel"
22
+ say "📦 Installing Babel packages (USE_BABEL_PACKAGES is set)", :yellow
23
+ say "✨ Also installing SWC packages for default config compatibility", :green
24
+ elsif ENV["JAVASCRIPT_TRANSPILER"]
25
+ @transpiler_to_install = ENV["JAVASCRIPT_TRANSPILER"]
26
+ say "📦 Installing #{@transpiler_to_install} packages", :blue
27
+ else
28
+ # Default to swc (matches the default in shakapacker.yml)
29
+ @transpiler_to_install = "swc"
30
+ say "✨ Installing SWC packages (20x faster than Babel)", :green
31
+ end
32
+
33
+ # Copy config file
34
+ copy_file "#{install_dir}/config/shakapacker.yml", "config/shakapacker.yml", force_option
35
+
36
+ # Update config if USE_BABEL_PACKAGES is set to ensure babel is used at runtime
37
+ if @transpiler_to_install == "babel" && !ENV["JAVASCRIPT_TRANSPILER"]
38
+ # When USE_BABEL_PACKAGES is set, update the config to use babel
39
+ gsub_file "config/shakapacker.yml", "javascript_transpiler: 'swc'", "javascript_transpiler: 'babel'"
40
+ say " 📝 Updated config/shakapacker.yml to use Babel transpiler", :green
41
+ end
42
+
43
+ # Detect TypeScript usage
44
+ # Auto-detect from tsconfig.json or explicit via SHAKAPACKER_USE_TYPESCRIPT env var
45
+ @use_typescript = File.exist?(Rails.root.join("tsconfig.json")) ||
46
+ ENV["SHAKAPACKER_USE_TYPESCRIPT"] == "true"
47
+ assets_bundler = ENV["SHAKAPACKER_ASSETS_BUNDLER"] || "webpack"
48
+ config_extension = @use_typescript ? "ts" : "js"
49
+
50
+ say "Copying #{assets_bundler} core config (#{config_extension.upcase})"
51
+ config_file = "#{assets_bundler}.config.#{config_extension}"
52
+ source_config = "#{install_dir}/config/#{assets_bundler}/#{config_file}"
53
+ dest_config = "config/#{assets_bundler}/#{config_file}"
54
+
55
+ empty_directory "config/#{assets_bundler}"
56
+ copy_file source_config, dest_config, force_option
57
+
58
+ if @use_typescript
59
+ say " ✨ Using TypeScript config for enhanced type safety", :green
60
+ end
15
61
 
16
62
  if Dir.exist?(Shakapacker.config.source_path)
17
63
  say "The packs app source directory already exists"
18
64
  else
19
65
  say "Creating packs app source directory"
20
66
  empty_directory "app/javascript/packs"
21
- copy_file "#{__dir__}/application.js", "app/javascript/packs/application.js"
67
+ copy_file "#{install_dir}/application.js", "app/javascript/packs/application.js"
22
68
  end
23
69
 
24
- apply "#{__dir__}/binstubs.rb"
70
+ apply "#{install_dir}/binstubs.rb"
25
71
 
26
72
  git_ignore_path = Rails.root.join(".gitignore")
27
73
  if File.exist?(git_ignore_path)
@@ -44,17 +90,8 @@ else
44
90
  say %( Add <%= javascript_pack_tag "application" %> within the <head> tag in your custom layout.)
45
91
  end
46
92
 
47
- def package_json
48
- @package_json ||= PackageJson.new
49
- end
50
-
51
93
  # setup the package manager with default values
52
- package_json.merge! do |pj|
53
- babel = pj.fetch("babel", {})
54
-
55
- babel["presets"] ||= []
56
- babel["presets"].push("./node_modules/shakapacker/package/babel/preset.js")
57
-
94
+ @package_json.merge! do |pj|
58
95
  package_manager = pj.fetch("packageManager") do
59
96
  "#{Shakapacker::Utils::Manager.guess_binary}@#{Shakapacker::Utils::Manager.guess_version}"
60
97
  end
@@ -63,7 +100,6 @@ package_json.merge! do |pj|
63
100
  "name" => "app",
64
101
  "private" => true,
65
102
  "version" => "0.1.0",
66
- "babel" => babel,
67
103
  "browserslist" => [
68
104
  "defaults"
69
105
  ],
@@ -75,7 +111,7 @@ Shakapacker::Utils::Manager.error_unless_package_manager_is_obvious!
75
111
 
76
112
  # Ensure there is `system!("bin/yarn")` command in `./bin/setup` file
77
113
  if (setup_path = Rails.root.join("bin/setup")).exist?
78
- native_install_command = package_json.manager.native_install_command.join(" ")
114
+ native_install_command = @package_json.manager.native_install_command.join(" ")
79
115
 
80
116
  say "Run #{native_install_command} during bin/setup"
81
117
 
@@ -104,40 +140,126 @@ if (setup_path = Rails.root.join("bin/setup")).exist?
104
140
  end
105
141
  end
106
142
 
107
- def add_dependencies(dependencies, type)
108
- package_json.manager.add!(dependencies, type: type)
109
- rescue PackageJson::Error
110
- say "Shakapacker installation failed 😭 See above for details.", :red
111
- exit 1
112
- end
143
+ Dir.chdir(Rails.root) do
144
+ # In CI, use the pre-packed tarball if available
145
+ if ENV["SHAKAPACKER_NPM_PACKAGE"]
146
+ package_path = ENV["SHAKAPACKER_NPM_PACKAGE"]
113
147
 
114
- def fetch_peer_dependencies
115
- PackageJson.read("#{__dir__}/../../").fetch("peerDependencies")
116
- end
148
+ # Validate package path to prevent directory traversal and invalid file types
149
+ begin
150
+ # Resolve to absolute path
151
+ absolute_path = File.expand_path(package_path)
117
152
 
118
- Dir.chdir(Rails.root) do
119
- npm_version = Shakapacker::Utils::VersionSyntaxConverter.new.rubygem_to_npm(Shakapacker::VERSION)
120
- say "Installing shakapacker@#{npm_version}"
121
- add_dependencies(["shakapacker@#{npm_version}"], :production)
122
-
123
- package_json.merge! do |pj|
124
- {
125
- "dependencies" => pj["dependencies"].merge({
126
- # TODO: workaround for test suite - long-run need to actually account for diff pkg manager behaviour
127
- "shakapacker" => pj["dependencies"]["shakapacker"].delete_prefix("^")
128
- })
129
- }
153
+ # Reject paths containing directory traversal
154
+ if package_path.include?("..") || absolute_path.include?("..")
155
+ say " Security Error: Package path contains directory traversal: #{package_path}", :red
156
+ exit 1
157
+ end
158
+
159
+ # Ensure filename ends with .tgz or .tar.gz
160
+ unless absolute_path.end_with?(".tgz", ".tar.gz")
161
+ say "❌ Security Error: Package must be a .tgz or .tar.gz file: #{package_path}", :red
162
+ exit 1
163
+ end
164
+
165
+ # Check existence only after validation
166
+ if File.exist?(absolute_path)
167
+ say "📦 Installing shakapacker from local package: #{absolute_path}", :cyan
168
+ begin
169
+ @package_json.manager.add!([absolute_path], type: :production)
170
+ rescue PackageJson::Error
171
+ say "Shakapacker installation failed 😭 See above for details.", :red
172
+ exit 1
173
+ end
174
+ else
175
+ say "⚠️ SHAKAPACKER_NPM_PACKAGE set but file not found: #{absolute_path}", :yellow
176
+ say "Falling back to npm registry...", :yellow
177
+ npm_version = Shakapacker::Utils::VersionSyntaxConverter.new.rubygem_to_npm(Shakapacker::VERSION)
178
+ begin
179
+ @package_json.manager.add!(["shakapacker@#{npm_version}"], type: :production)
180
+ rescue PackageJson::Error
181
+ say "Shakapacker installation failed 😭 See above for details.", :red
182
+ exit 1
183
+ end
184
+ end
185
+ rescue => e
186
+ say "❌ Error validating package path: #{e.message}", :red
187
+ exit 1
188
+ end
189
+ else
190
+ npm_version = Shakapacker::Utils::VersionSyntaxConverter.new.rubygem_to_npm(Shakapacker::VERSION)
191
+ say "Installing shakapacker@#{npm_version}"
192
+ begin
193
+ @package_json.manager.add!(["shakapacker@#{npm_version}"], type: :production)
194
+ rescue PackageJson::Error
195
+ say "Shakapacker installation failed 😭 See above for details.", :red
196
+ exit 1
197
+ end
198
+ end
199
+
200
+ @package_json.merge! do |pj|
201
+ if pj["dependencies"] && pj["dependencies"]["shakapacker"]
202
+ {
203
+ "dependencies" => pj["dependencies"].merge({
204
+ # TODO: workaround for test suite - long-run need to actually account for diff pkg manager behaviour
205
+ "shakapacker" => pj["dependencies"]["shakapacker"].delete_prefix("^")
206
+ })
207
+ }
208
+ else
209
+ pj
210
+ end
211
+ end
212
+
213
+ # Inline fetch_peer_dependencies and fetch_common_dependencies
214
+ peers = PackageJson.read(install_dir).fetch(ENV["SHAKAPACKER_ASSETS_BUNDLER"] || "webpack")
215
+ common_deps = ENV["SKIP_COMMON_LOADERS"] ? {} : PackageJson.read(install_dir).fetch("common")
216
+ peers = peers.merge(common_deps)
217
+
218
+ # Add transpiler-specific dependencies based on detected/configured transpiler
219
+ # Inline the logic here since methods can't be called before they're defined in Rails templates
220
+
221
+ # Install transpiler-specific dependencies
222
+ # When USE_BABEL_PACKAGES is set, install both babel AND swc
223
+ # This ensures backward compatibility while supporting the default config
224
+ if @transpiler_to_install == "babel"
225
+ # Install babel packages
226
+ babel_deps = PackageJson.read(install_dir).fetch("babel")
227
+ peers = peers.merge(babel_deps)
228
+
229
+ # Also install SWC since that's what the default config uses
230
+ # This ensures the runtime works regardless of config
231
+ swc_deps = PackageJson.read(install_dir).fetch("swc")
232
+ peers = peers.merge(swc_deps)
233
+
234
+ say "ℹ️ Installing both Babel and SWC packages for compatibility:", :blue
235
+ say " - Babel packages are installed as requested via USE_BABEL_PACKAGES", :blue
236
+ say " - SWC packages are also installed to ensure the default config works", :blue
237
+ say " - Your actual transpiler will be determined by your shakapacker.yml configuration", :blue
238
+ elsif @transpiler_to_install == "swc"
239
+ swc_deps = PackageJson.read(install_dir).fetch("swc")
240
+ peers = peers.merge(swc_deps)
241
+ elsif @transpiler_to_install == "esbuild"
242
+ esbuild_deps = PackageJson.read(install_dir).fetch("esbuild")
243
+ peers = peers.merge(esbuild_deps)
130
244
  end
131
245
 
132
- peers = fetch_peer_dependencies
133
246
  dev_dependency_packages = ["webpack-dev-server"]
134
247
 
135
248
  dependencies_to_add = []
136
249
  dev_dependencies_to_add = []
137
250
 
138
251
  peers.each do |(package, version)|
139
- major_version = version.match(/(\d+)/)[1]
140
- entry = "#{package}@#{major_version}"
252
+ # Handle versions like "^1.3.0" or ">= 4 || 5"
253
+ if version.start_with?("^", "~") || version.match?(/^\d+\.\d+/)
254
+ # Already has proper version format, use as-is
255
+ entry = "#{package}@#{version}"
256
+ else
257
+ # Extract major version from complex version strings like ">= 4 || 5"
258
+ # Fallback to "latest" if no version number found
259
+ version_match = version.split("||").last.match(/(\d+)/)
260
+ major_version = version_match ? version_match[1] : "latest"
261
+ entry = "#{package}@#{major_version}"
262
+ end
141
263
 
142
264
  if dev_dependency_packages.include? package
143
265
  dev_dependencies_to_add << entry
@@ -147,8 +269,36 @@ Dir.chdir(Rails.root) do
147
269
  end
148
270
 
149
271
  say "Adding shakapacker peerDependencies"
150
- add_dependencies(dependencies_to_add, :production)
272
+ begin
273
+ @package_json.manager.add!(dependencies_to_add, type: :production)
274
+ rescue PackageJson::Error
275
+ say "Shakapacker installation failed 😭 See above for details.", :red
276
+ exit 1
277
+ end
151
278
 
152
279
  say "Installing webpack-dev-server for live reloading as a development dependency"
153
- add_dependencies(dev_dependencies_to_add, :dev)
280
+ begin
281
+ @package_json.manager.add!(dev_dependencies_to_add, type: :dev)
282
+ rescue PackageJson::Error
283
+ say "Shakapacker installation failed 😭 See above for details.", :red
284
+ exit 1
285
+ end
286
+
287
+ # Configure babel preset in package.json if babel is being used
288
+ if @transpiler_to_install == "babel"
289
+ @package_json.merge! do |pj|
290
+ babel = pj.fetch("babel", {})
291
+ babel["presets"] ||= []
292
+ unless babel["presets"].include?("./node_modules/shakapacker/package/babel/preset.js")
293
+ babel["presets"].push("./node_modules/shakapacker/package/babel/preset.js")
294
+ end
295
+ { "babel" => babel }
296
+ end
297
+ end
298
+ end
299
+
300
+ # Helper methods defined at the end (Rails template convention)
301
+
302
+ def package_json
303
+ @package_json
154
304
  end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "fileutils"
5
+
6
+ module Shakapacker
7
+ # Provides functionality to switch between webpack and rspack bundlers
8
+ class BundlerSwitcher
9
+ SHAKAPACKER_CONFIG = "config/shakapacker.yml"
10
+ CUSTOM_DEPS_CONFIG = ".shakapacker-switch-bundler-dependencies.yml"
11
+
12
+ # Default dependencies for each bundler (package names only, no versions)
13
+ DEFAULT_RSPACK_DEPS = {
14
+ dev: %w[@rspack/cli @rspack/plugin-react-refresh],
15
+ prod: %w[@rspack/core rspack-manifest-plugin]
16
+ }.freeze
17
+
18
+ DEFAULT_WEBPACK_DEPS = {
19
+ dev: %w[webpack webpack-cli webpack-dev-server @pmmmwh/react-refresh-webpack-plugin @swc/core swc-loader],
20
+ prod: %w[webpack-assets-manifest webpack-merge]
21
+ }.freeze
22
+
23
+ attr_reader :root_path
24
+
25
+ def initialize(root_path = nil)
26
+ @root_path = root_path || (defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd))
27
+ end
28
+
29
+ def current_bundler
30
+ config = load_yaml_config(config_path)
31
+ config.dig("default", "assets_bundler") || "webpack"
32
+ end
33
+
34
+ def switch_to(bundler, install_deps: false, no_uninstall: false)
35
+ unless %w[webpack rspack].include?(bundler)
36
+ raise ArgumentError, "Invalid bundler: #{bundler}. Must be 'webpack' or 'rspack'"
37
+ end
38
+
39
+ current = current_bundler
40
+ if current == bundler && !install_deps
41
+ puts "✅ Already using #{bundler}"
42
+ return
43
+ end
44
+
45
+ if current == bundler && install_deps
46
+ puts "✅ Already using #{bundler} - reinstalling dependencies as requested"
47
+ manage_dependencies(bundler, install_deps, switching: false, no_uninstall: no_uninstall)
48
+ return
49
+ end
50
+
51
+ update_config(bundler)
52
+
53
+ puts "✅ Switched from #{current} to #{bundler}"
54
+ puts ""
55
+ puts "📝 Configuration updated in #{SHAKAPACKER_CONFIG}"
56
+
57
+ manage_dependencies(bundler, install_deps, no_uninstall: no_uninstall)
58
+
59
+ puts ""
60
+ puts "🎯 Next steps:"
61
+ puts " 1. Restart your dev server: bin/dev"
62
+ puts " 2. Verify build works: bin/shakapacker"
63
+ puts ""
64
+ puts "💡 Tip: Both webpack and rspack can coexist in package.json during migration"
65
+ puts " Use --install-deps to automatically manage dependencies, or manage manually"
66
+ puts " Use --no-uninstall to skip removing old bundler packages (faster switching)"
67
+ end
68
+
69
+ def init_config
70
+ if File.exist?(custom_config_path)
71
+ puts "⚠️ #{CUSTOM_DEPS_CONFIG} already exists"
72
+ return
73
+ end
74
+
75
+ config = {
76
+ "rspack" => {
77
+ "devDependencies" => DEFAULT_RSPACK_DEPS[:dev],
78
+ "dependencies" => DEFAULT_RSPACK_DEPS[:prod]
79
+ },
80
+ "webpack" => {
81
+ "devDependencies" => DEFAULT_WEBPACK_DEPS[:dev],
82
+ "dependencies" => DEFAULT_WEBPACK_DEPS[:prod]
83
+ }
84
+ }
85
+
86
+ File.write(custom_config_path, YAML.dump(config))
87
+ puts "✅ Created #{CUSTOM_DEPS_CONFIG}"
88
+ puts ""
89
+ puts "You can now customize the dependencies for each bundler in this file."
90
+ puts "The script will automatically use these custom dependencies when switching bundlers."
91
+ end
92
+
93
+ def show_usage
94
+ current = current_bundler
95
+ puts "Current bundler: #{current}"
96
+ puts ""
97
+ puts "Usage:"
98
+ puts " rails shakapacker:switch_bundler [webpack|rspack] [OPTIONS]"
99
+ puts " rake shakapacker:switch_bundler [webpack|rspack] -- [OPTIONS]"
100
+ puts ""
101
+ puts "Options:"
102
+ puts " --install-deps Automatically install/uninstall dependencies"
103
+ puts " --no-uninstall Skip uninstalling old bundler packages (faster, keeps both bundlers)"
104
+ puts " --init-config Create #{CUSTOM_DEPS_CONFIG} with default dependencies"
105
+ puts " --help, -h Show this help message"
106
+ puts ""
107
+ puts "Examples:"
108
+ puts " # Using rails command"
109
+ puts " rails shakapacker:switch_bundler rspack --install-deps"
110
+ puts " rails shakapacker:switch_bundler webpack --install-deps --no-uninstall"
111
+ puts " rails shakapacker:switch_bundler --init-config"
112
+ puts ""
113
+ puts " # Using rake command (note the -- separator)"
114
+ puts " rake shakapacker:switch_bundler rspack -- --install-deps"
115
+ puts " rake shakapacker:switch_bundler webpack -- --install-deps --no-uninstall"
116
+ puts " rake shakapacker:switch_bundler -- --init-config"
117
+ end
118
+
119
+ private
120
+
121
+ def config_path
122
+ root_path.join(SHAKAPACKER_CONFIG)
123
+ end
124
+
125
+ def custom_config_path
126
+ root_path.join(CUSTOM_DEPS_CONFIG)
127
+ end
128
+
129
+ def load_dependencies
130
+ if File.exist?(custom_config_path)
131
+ puts "📝 Using custom dependencies from #{CUSTOM_DEPS_CONFIG}"
132
+ begin
133
+ custom = load_yaml_config(custom_config_path)
134
+ rescue Psych::SyntaxError => e
135
+ puts "❌ Error parsing #{CUSTOM_DEPS_CONFIG}: #{e.message}"
136
+ puts " Please fix the YAML syntax or delete the file to use defaults"
137
+ raise
138
+ end
139
+ rspack_deps = {
140
+ dev: custom.dig("rspack", "devDependencies") || DEFAULT_RSPACK_DEPS[:dev],
141
+ prod: custom.dig("rspack", "dependencies") || DEFAULT_RSPACK_DEPS[:prod]
142
+ }
143
+ webpack_deps = {
144
+ dev: custom.dig("webpack", "devDependencies") || DEFAULT_WEBPACK_DEPS[:dev],
145
+ prod: custom.dig("webpack", "dependencies") || DEFAULT_WEBPACK_DEPS[:prod]
146
+ }
147
+ [rspack_deps, webpack_deps]
148
+ else
149
+ [DEFAULT_RSPACK_DEPS, DEFAULT_WEBPACK_DEPS]
150
+ end
151
+ end
152
+
153
+ def update_config(bundler)
154
+ content = File.read(config_path)
155
+
156
+ # Replace assets_bundler value (handles spaces, tabs, and various quote styles)
157
+ # Only matches uncommented lines
158
+ content.gsub!(/^([ \t]*assets_bundler:[ \t]*['"]?)(webpack|rspack)(['"]?)/, "\\1#{bundler}\\3")
159
+
160
+ # Update javascript_transpiler recommendation for rspack
161
+ # Only update if not already set to swc and only on uncommented lines
162
+ if bundler == "rspack" && content !~ /^[ \t]*javascript_transpiler:[ \t]*['"]?swc['"]?/
163
+ content.gsub!(/^([ \t]*javascript_transpiler:[ \t]*['"]?)\w+(['"]?)/, "\\1swc\\2")
164
+ end
165
+
166
+ File.write(config_path, content)
167
+ end
168
+
169
+ def manage_dependencies(bundler, install_deps, switching: true, no_uninstall: false)
170
+ rspack_deps, webpack_deps = load_dependencies
171
+ deps_to_install = bundler == "rspack" ? rspack_deps : webpack_deps
172
+ deps_to_remove = bundler == "rspack" ? webpack_deps : rspack_deps
173
+
174
+ if install_deps
175
+ puts ""
176
+ puts "📦 Managing dependencies..."
177
+ puts ""
178
+
179
+ # Show what will be removed (only when switching and not no_uninstall)
180
+ if switching && !no_uninstall && (!deps_to_remove[:dev].empty? || !deps_to_remove[:prod].empty?)
181
+ puts " 🗑️ Removing:"
182
+ deps_to_remove[:dev].each { |dep| puts " - #{dep} (dev)" }
183
+ deps_to_remove[:prod].each { |dep| puts " - #{dep} (prod)" }
184
+ puts ""
185
+ elsif switching && no_uninstall
186
+ puts " ⏭️ Skipping uninstall (--no-uninstall)"
187
+ puts ""
188
+ end
189
+
190
+ # Show what will be installed
191
+ if !deps_to_install[:dev].empty? || !deps_to_install[:prod].empty?
192
+ puts " 📦 Installing:"
193
+ deps_to_install[:dev].each { |dep| puts " - #{dep} (dev)" }
194
+ deps_to_install[:prod].each { |dep| puts " - #{dep} (prod)" }
195
+ puts ""
196
+ end
197
+
198
+ # Remove old bundler dependencies (only when switching and not no_uninstall)
199
+ if switching && !no_uninstall
200
+ remove_dependencies(deps_to_remove)
201
+ end
202
+
203
+ # Install new bundler dependencies
204
+ install_dependencies(deps_to_install)
205
+
206
+ puts " ✅ Dependencies updated"
207
+ else
208
+ print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
209
+ end
210
+ end
211
+
212
+ def remove_dependencies(deps)
213
+ package_json = get_package_json
214
+
215
+ unless deps[:dev].empty?
216
+ unless package_json.manager.remove(deps[:dev])
217
+ puts " ⚠️ Warning: Failed to uninstall some dev dependencies"
218
+ end
219
+ end
220
+
221
+ unless deps[:prod].empty?
222
+ unless package_json.manager.remove(deps[:prod])
223
+ puts " ⚠️ Warning: Failed to uninstall some prod dependencies"
224
+ end
225
+ end
226
+ end
227
+
228
+ def install_dependencies(deps)
229
+ package_json = get_package_json
230
+
231
+ unless deps[:dev].empty?
232
+ unless package_json.manager.add(deps[:dev], type: :dev)
233
+ puts "❌ Failed to install dev dependencies"
234
+ raise "Failed to install dev dependencies"
235
+ end
236
+ end
237
+
238
+ unless deps[:prod].empty?
239
+ unless package_json.manager.add(deps[:prod], type: :production)
240
+ puts "❌ Failed to install prod dependencies"
241
+ raise "Failed to install prod dependencies"
242
+ end
243
+ end
244
+
245
+ # Run a full install to ensure optional dependencies (like native bindings) are properly resolved
246
+ # This is especially important for packages like @rspack/core that use platform-specific native modules
247
+ unless package_json.manager.install
248
+ puts "❌ Failed to run full install to resolve optional dependencies"
249
+ raise "Failed to run full install"
250
+ end
251
+ end
252
+
253
+ def get_package_json
254
+ require "package_json"
255
+ PackageJson.read(root_path)
256
+ end
257
+
258
+ def print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
259
+ puts ""
260
+ puts "⚠️ Dependencies not automatically installed (use --install-deps to auto-install)"
261
+ puts ""
262
+
263
+ package_manager = detect_package_manager
264
+ target_name = bundler == "rspack" ? "rspack" : "webpack"
265
+ old_name = bundler == "rspack" ? "webpack" : "rspack"
266
+
267
+ puts "📦 To install #{target_name} dependencies, run:"
268
+ print_install_commands(package_manager, deps_to_install)
269
+ puts ""
270
+ puts "🗑️ To remove #{old_name} dependencies, run:"
271
+ print_uninstall_commands(package_manager, deps_to_remove)
272
+ end
273
+
274
+ def detect_package_manager
275
+ get_package_json.manager.binary
276
+ rescue StandardError
277
+ "npm" # Fallback to npm if detection fails
278
+ end
279
+
280
+ def print_install_commands(package_manager, deps)
281
+ case package_manager
282
+ when "yarn"
283
+ puts " yarn add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
284
+ puts " yarn add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
285
+ when "pnpm"
286
+ puts " pnpm add -D #{deps[:dev].join(' ')}" unless deps[:dev].empty?
287
+ puts " pnpm add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
288
+ when "bun"
289
+ puts " bun add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
290
+ puts " bun add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
291
+ else # npm
292
+ puts " npm install --save-dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
293
+ puts " npm install --save #{deps[:prod].join(' ')}" unless deps[:prod].empty?
294
+ end
295
+ end
296
+
297
+ def print_uninstall_commands(package_manager, deps)
298
+ case package_manager
299
+ when "yarn"
300
+ puts " yarn remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
301
+ puts " yarn remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
302
+ when "pnpm"
303
+ puts " pnpm remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
304
+ puts " pnpm remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
305
+ when "bun"
306
+ puts " bun remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
307
+ puts " bun remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
308
+ else # npm
309
+ puts " npm uninstall #{deps[:dev].join(' ')}" unless deps[:dev].empty?
310
+ puts " npm uninstall #{deps[:prod].join(' ')}" unless deps[:prod].empty?
311
+ end
312
+ end
313
+
314
+ # Load YAML config file with Ruby version compatibility
315
+ # Ruby 3.1+ supports aliases: keyword, older versions need YAML.safe_load
316
+ def load_yaml_config(path)
317
+ if YAML.respond_to?(:unsafe_load_file)
318
+ # Ruby 3.1+: Use unsafe_load_file to support aliases/anchors
319
+ YAML.unsafe_load_file(path)
320
+ else
321
+ # Ruby 2.7-3.0: Use safe_load with aliases enabled
322
+ YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: true)
323
+ end
324
+ rescue ArgumentError
325
+ # Ruby 2.7 doesn't support aliases keyword - fall back to YAML.load
326
+ YAML.load(File.read(path)) # rubocop:disable Security/YAMLLoad
327
+ end
328
+ end
329
+ end