shakapacker 8.4.0 → 9.7.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 (265) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/commands/address-review.md +206 -0
  3. data/.claude/commands/update-changelog.md +354 -0
  4. data/.github/ISSUE_TEMPLATE/bug_report.md +6 -9
  5. data/.github/ISSUE_TEMPLATE/feature_request.md +6 -8
  6. data/.github/STATUS.md +1 -0
  7. data/.github/actionlint-matcher.json +17 -0
  8. data/.github/workflows/claude-code-review.yml +45 -0
  9. data/.github/workflows/claude.yml +55 -0
  10. data/.github/workflows/dummy.yml +18 -5
  11. data/.github/workflows/eslint-validation.yml +46 -0
  12. data/.github/workflows/generator.yml +38 -22
  13. data/.github/workflows/node.yml +116 -2
  14. data/.github/workflows/ruby.yml +57 -15
  15. data/.github/workflows/test-bundlers.yml +180 -0
  16. data/.gitignore +27 -0
  17. data/.husky/pre-commit +2 -0
  18. data/.npmignore +56 -0
  19. data/.prettierignore +7 -0
  20. data/.rubocop.yml +2 -0
  21. data/.yalcignore +26 -0
  22. data/CHANGELOG.md +487 -19
  23. data/CLAUDE.md +63 -0
  24. data/CONTRIBUTING.md +268 -21
  25. data/ESLINT_TECHNICAL_DEBT.md +165 -0
  26. data/README.md +497 -137
  27. data/Rakefile +44 -4
  28. data/TODO.md +58 -0
  29. data/TODO_v9.md +97 -0
  30. data/bin/conductor-exec +24 -0
  31. data/bin/shakapacker-config +11 -0
  32. data/conductor-setup.sh +147 -0
  33. data/conductor.json +9 -0
  34. data/docs/api-reference.md +519 -0
  35. data/docs/cdn_setup.md +384 -0
  36. data/docs/common-upgrades.md +695 -0
  37. data/docs/configuration.md +845 -0
  38. data/docs/css-modules-export-mode.md +566 -0
  39. data/docs/customizing_babel_config.md +16 -16
  40. data/docs/deployment.md +78 -7
  41. data/docs/developing_shakapacker.md +6 -0
  42. data/docs/early_hints.md +433 -0
  43. data/docs/early_hints_manual_api.md +454 -0
  44. data/docs/feature_testing.md +492 -0
  45. data/docs/node_package_api.md +70 -0
  46. data/docs/optional-peer-dependencies.md +203 -0
  47. data/docs/peer-dependencies.md +71 -0
  48. data/docs/precompile_hook.md +486 -0
  49. data/docs/preventing_fouc.md +132 -0
  50. data/docs/react.md +58 -48
  51. data/docs/releasing.md +288 -0
  52. data/docs/rspack.md +218 -0
  53. data/docs/rspack_migration_guide.md +862 -0
  54. data/docs/sprockets.md +1 -0
  55. data/docs/style_loader_vs_mini_css.md +12 -12
  56. data/docs/subresource_integrity.md +13 -7
  57. data/docs/transpiler-migration.md +212 -0
  58. data/docs/transpiler-performance.md +200 -0
  59. data/docs/troubleshooting.md +272 -24
  60. data/docs/typescript-migration.md +388 -0
  61. data/docs/typescript.md +103 -0
  62. data/docs/using_esbuild_loader.md +12 -12
  63. data/docs/using_swc_loader.md +121 -16
  64. data/docs/v6_upgrade.md +42 -19
  65. data/docs/v7_upgrade.md +8 -6
  66. data/docs/v8_upgrade.md +13 -12
  67. data/docs/v9_upgrade.md +616 -0
  68. data/eslint.config.fast.js +254 -0
  69. data/eslint.config.js +309 -0
  70. data/jest.config.js +8 -1
  71. data/knip.ts +61 -0
  72. data/lib/install/bin/shakapacker +4 -6
  73. data/lib/install/bin/shakapacker-config +11 -0
  74. data/lib/install/bin/shakapacker-dev-server +1 -1
  75. data/lib/install/binstubs.rb +6 -2
  76. data/lib/install/config/rspack/rspack.config.js +6 -0
  77. data/lib/install/config/rspack/rspack.config.ts +7 -0
  78. data/lib/install/config/shakapacker.yml +75 -12
  79. data/lib/install/config/webpack/webpack.config.ts +7 -0
  80. data/lib/install/package.json +38 -0
  81. data/lib/install/template.rb +207 -45
  82. data/lib/shakapacker/build_config_loader.rb +147 -0
  83. data/lib/shakapacker/bundler_switcher.rb +415 -0
  84. data/lib/shakapacker/compiler.rb +87 -0
  85. data/lib/shakapacker/configuration.rb +475 -6
  86. data/lib/shakapacker/dev_server.rb +88 -1
  87. data/lib/shakapacker/dev_server_runner.rb +240 -6
  88. data/lib/shakapacker/doctor.rb +1191 -0
  89. data/lib/shakapacker/env.rb +19 -3
  90. data/lib/shakapacker/helper.rb +411 -14
  91. data/lib/shakapacker/install/env.rb +33 -0
  92. data/lib/shakapacker/instance.rb +93 -4
  93. data/lib/shakapacker/manifest.rb +167 -30
  94. data/lib/shakapacker/railtie.rb +4 -0
  95. data/lib/shakapacker/rspack_runner.rb +19 -0
  96. data/lib/shakapacker/runner.rb +668 -9
  97. data/lib/shakapacker/swc_migrator.rb +384 -0
  98. data/lib/shakapacker/utils/manager.rb +2 -0
  99. data/lib/shakapacker/utils/version_syntax_converter.rb +1 -1
  100. data/lib/shakapacker/version.rb +1 -1
  101. data/lib/shakapacker/version_checker.rb +1 -1
  102. data/lib/shakapacker/webpack_runner.rb +4 -42
  103. data/lib/shakapacker.rb +159 -1
  104. data/lib/tasks/shakapacker/binstubs.rake +4 -2
  105. data/lib/tasks/shakapacker/check_binstubs.rake +2 -2
  106. data/lib/tasks/shakapacker/doctor.rake +48 -0
  107. data/lib/tasks/shakapacker/export_bundler_config.rake +68 -0
  108. data/lib/tasks/shakapacker/install.rake +16 -4
  109. data/lib/tasks/shakapacker/migrate_to_swc.rake +13 -0
  110. data/lib/tasks/shakapacker/switch_bundler.rake +72 -0
  111. data/lib/tasks/shakapacker.rake +2 -0
  112. data/package/.npmignore +4 -0
  113. data/package/babel/preset.ts +59 -0
  114. data/package/config.ts +189 -0
  115. data/package/configExporter/buildValidator.ts +906 -0
  116. data/package/configExporter/cli.ts +1748 -0
  117. data/package/configExporter/configDocs.ts +102 -0
  118. data/package/configExporter/configFile.ts +663 -0
  119. data/package/configExporter/fileWriter.ts +112 -0
  120. data/package/configExporter/index.ts +15 -0
  121. data/package/configExporter/types.ts +159 -0
  122. data/package/configExporter/yamlSerializer.ts +391 -0
  123. data/package/dev_server.ts +27 -0
  124. data/package/env.ts +92 -0
  125. data/package/environments/__type-tests__/rspack-plugin-compatibility.ts +36 -0
  126. data/package/environments/base.ts +147 -0
  127. data/package/environments/development.ts +88 -0
  128. data/package/environments/production.ts +82 -0
  129. data/package/environments/test.ts +55 -0
  130. data/package/environments/types.ts +98 -0
  131. data/package/esbuild/index.ts +40 -0
  132. data/package/index.d.ts +68 -93
  133. data/package/index.d.ts.template +72 -0
  134. data/package/index.ts +104 -0
  135. data/package/loaders.d.ts +28 -0
  136. data/package/optimization/rspack.ts +36 -0
  137. data/package/optimization/webpack.ts +55 -0
  138. data/package/plugins/envFilter.ts +82 -0
  139. data/package/plugins/rspack.ts +119 -0
  140. data/package/plugins/webpack.ts +82 -0
  141. data/package/rspack/index.ts +91 -0
  142. data/package/rules/{babel.js → babel.ts} +2 -2
  143. data/package/rules/{coffee.js → coffee.ts} +1 -1
  144. data/package/rules/css.ts +3 -0
  145. data/package/rules/{erb.js → erb.ts} +1 -1
  146. data/package/rules/esbuild.ts +10 -0
  147. data/package/rules/file.ts +41 -0
  148. data/package/rules/{jscommon.js → jscommon.ts} +5 -4
  149. data/package/rules/{less.js → less.ts} +4 -4
  150. data/package/rules/raw.ts +28 -0
  151. data/package/rules/rspack.ts +174 -0
  152. data/package/rules/sass.ts +21 -0
  153. data/package/rules/{stylus.js → stylus.ts} +4 -8
  154. data/package/rules/swc.ts +10 -0
  155. data/package/rules/{index.js → webpack.ts} +1 -2
  156. data/package/swc/index.ts +54 -0
  157. data/package/types/README.md +90 -0
  158. data/package/types/index.ts +69 -0
  159. data/package/types.ts +105 -0
  160. data/package/utils/bundlerUtils.ts +232 -0
  161. data/package/utils/configPath.ts +6 -0
  162. data/package/utils/debug.ts +45 -0
  163. data/package/utils/defaultConfigPath.ts +7 -0
  164. data/package/utils/ensureManifestExists.ts +17 -0
  165. data/package/utils/errorCodes.ts +249 -0
  166. data/package/utils/errorHelpers.ts +152 -0
  167. data/package/utils/getStyleRule.ts +75 -0
  168. data/package/utils/helpers.ts +99 -0
  169. data/package/utils/{inliningCss.js → inliningCss.ts} +3 -3
  170. data/package/utils/pathValidation.ts +207 -0
  171. data/package/utils/requireOrError.ts +24 -0
  172. data/package/utils/snakeToCamelCase.ts +5 -0
  173. data/package/utils/typeGuards.ts +388 -0
  174. data/package/utils/validateDependencies.ts +61 -0
  175. data/package/webpack-types.d.ts +33 -0
  176. data/package/webpackDevServerConfig.ts +130 -0
  177. data/package.json +157 -18
  178. data/scripts/remove-use-strict.js +44 -0
  179. data/scripts/type-check-no-emit.js +27 -0
  180. data/shakapacker.gemspec +4 -2
  181. data/sig/shakapacker/commands.rbs +35 -0
  182. data/sig/shakapacker/compiler.rbs +65 -0
  183. data/sig/shakapacker/compiler_strategy.rbs +41 -0
  184. data/sig/shakapacker/configuration.rbs +140 -0
  185. data/sig/shakapacker/dev_server.rbs +56 -0
  186. data/sig/shakapacker/env.rbs +25 -0
  187. data/sig/shakapacker/helper.rbs +98 -0
  188. data/sig/shakapacker/instance.rbs +46 -0
  189. data/sig/shakapacker/manifest.rbs +69 -0
  190. data/sig/shakapacker/version.rbs +4 -0
  191. data/sig/shakapacker.rbs +66 -0
  192. data/test/configExporter/buildValidator.test.js +1295 -0
  193. data/test/configExporter/configFile.test.js +393 -0
  194. data/test/configExporter/integration.test.js +262 -0
  195. data/test/helpers.js +1 -1
  196. data/test/package/bundlerUtils.rspack.test.js +145 -0
  197. data/test/package/bundlerUtils.test.js +97 -0
  198. data/test/package/config.test.js +14 -0
  199. data/test/package/configExporter/cli.test.js +440 -0
  200. data/test/package/configExporter/types.test.js +163 -0
  201. data/test/package/configExporter.test.js +491 -0
  202. data/test/package/env.test.js +42 -7
  203. data/test/package/environments/base.test.js +14 -4
  204. data/test/package/helpers.test.js +2 -2
  205. data/test/package/plugins/envFiltering.test.js +453 -0
  206. data/test/package/plugins/webpackSubresourceIntegrity.test.js +89 -0
  207. data/test/package/rspack/index.test.js +293 -0
  208. data/test/package/rspack/optimization.test.js +86 -0
  209. data/test/package/rspack/plugins.test.js +185 -0
  210. data/test/package/rspack/rules.test.js +229 -0
  211. data/test/package/rules/babel.test.js +65 -38
  212. data/test/package/rules/esbuild.test.js +13 -4
  213. data/test/package/rules/file.test.js +7 -1
  214. data/test/package/rules/raw.test.js +40 -7
  215. data/test/package/rules/sass-version-parsing.test.js +71 -0
  216. data/test/package/rules/sass.test.js +11 -6
  217. data/test/package/rules/sass1.test.js +8 -5
  218. data/test/package/rules/sass16.test.js +24 -0
  219. data/test/package/rules/swc.test.js +50 -39
  220. data/test/package/rules/webpack.test.js +35 -0
  221. data/test/package/staging.test.js +4 -3
  222. data/test/package/transpiler-defaults.test.js +169 -0
  223. data/test/package/utils/ensureManifestExists.test.js +51 -0
  224. data/test/package/yamlSerializer.test.js +204 -0
  225. data/test/peer-dependencies.sh +85 -0
  226. data/test/resolver.js +34 -3
  227. data/test/scripts/remove-use-strict.test.js +125 -0
  228. data/test/typescript/build.test.js +118 -0
  229. data/test/typescript/environments.test.js +107 -0
  230. data/test/typescript/pathValidation.test.js +186 -0
  231. data/test/typescript/requireOrError.test.js +49 -0
  232. data/test/typescript/securityValidation.test.js +182 -0
  233. data/tools/README.md +134 -0
  234. data/tools/css-modules-v9-codemod.js +179 -0
  235. data/tsconfig.eslint.json +9 -0
  236. data/tsconfig.json +38 -0
  237. data/yarn.lock +3202 -1097
  238. metadata +212 -44
  239. data/.eslintignore +0 -4
  240. data/.eslintrc.js +0 -36
  241. data/Gemfile.lock +0 -251
  242. data/package/babel/preset.js +0 -48
  243. data/package/config.js +0 -56
  244. data/package/dev_server.js +0 -23
  245. data/package/env.js +0 -48
  246. data/package/environments/base.js +0 -171
  247. data/package/environments/development.js +0 -13
  248. data/package/environments/production.js +0 -88
  249. data/package/environments/test.js +0 -3
  250. data/package/esbuild/index.js +0 -40
  251. data/package/index.js +0 -40
  252. data/package/rules/css.js +0 -3
  253. data/package/rules/esbuild.js +0 -10
  254. data/package/rules/file.js +0 -29
  255. data/package/rules/raw.js +0 -5
  256. data/package/rules/sass.js +0 -18
  257. data/package/rules/swc.js +0 -10
  258. data/package/swc/index.js +0 -50
  259. data/package/utils/configPath.js +0 -4
  260. data/package/utils/defaultConfigPath.js +0 -2
  261. data/package/utils/getStyleRule.js +0 -40
  262. data/package/utils/helpers.js +0 -62
  263. data/package/utils/snakeToCamelCase.js +0 -5
  264. data/package/webpackDevServerConfig.js +0 -71
  265. data/test/package/rules/index.test.js +0 -16
@@ -0,0 +1,147 @@
1
+ require "yaml"
2
+
3
+ module Shakapacker
4
+ class BuildConfigLoader
5
+ attr_reader :config_file_path
6
+
7
+ def initialize(config_file_path = nil)
8
+ @config_file_path = config_file_path || File.join(Dir.pwd, "config", "shakapacker-builds.yml")
9
+ end
10
+
11
+ def exists?
12
+ File.exist?(@config_file_path)
13
+ end
14
+
15
+ def load_build(build_name)
16
+ unless exists?
17
+ raise ArgumentError, "Config file not found: #{@config_file_path}\n" \
18
+ "Run 'bin/shakapacker --init' to generate a sample config file."
19
+ end
20
+
21
+ config = load_config
22
+ fetch_build_or_raise(config, build_name)
23
+ end
24
+
25
+ def resolve_build_config(build_name, default_bundler: "webpack")
26
+ config = load_config
27
+ build = fetch_build_or_raise(config, build_name)
28
+
29
+ # Resolve bundler with precedence: build.bundler > config.default_bundler > default_bundler
30
+ bundler = build["bundler"] || config["default_bundler"] || default_bundler
31
+
32
+ # Get environment variables
33
+ environment = build["environment"] || {}
34
+
35
+ # Get config file path if specified
36
+ config_file = build["config"]
37
+ if config_file
38
+ # Expand ${BUNDLER} variable
39
+ config_file = config_file.gsub("${BUNDLER}", bundler)
40
+ end
41
+
42
+ # Get bundler_env for --env flags
43
+ bundler_env = build["bundler_env"] || {}
44
+
45
+ # Get outputs
46
+ outputs = build["outputs"] || []
47
+
48
+ # Validate outputs
49
+ if outputs.empty?
50
+ raise ArgumentError, "Build '#{build_name}' has empty outputs array. " \
51
+ "Please specify at least one output type (client, server, or all)."
52
+ end
53
+
54
+ {
55
+ name: build_name,
56
+ description: build["description"],
57
+ bundler: bundler,
58
+ dev_server: build["dev_server"],
59
+ environment: environment,
60
+ bundler_env: bundler_env,
61
+ outputs: outputs,
62
+ config_file: config_file
63
+ }
64
+ end
65
+
66
+ def uses_dev_server?(build_config)
67
+ # Check explicit dev_server flag first (preferred)
68
+ # Only return early if the value is explicitly set (not nil)
69
+ return build_config[:dev_server] unless build_config[:dev_server].nil?
70
+
71
+ # Fallback: check environment variables for backward compatibility
72
+ env = build_config[:environment]
73
+ return false unless env
74
+
75
+ # Handle both string "true" and boolean true from YAML
76
+ %w[WEBPACK_SERVE HMR].any? do |key|
77
+ value = env[key]
78
+ value.to_s.strip.casecmp("true").zero?
79
+ end
80
+ end
81
+
82
+ def list_builds
83
+ config = load_config
84
+ builds = config["builds"]
85
+
86
+ puts "\nAvailable builds in #{@config_file_path}:\n\n"
87
+
88
+ builds.each do |name, build|
89
+ bundler = build["bundler"] || config["default_bundler"] || "webpack (default)"
90
+ outputs = build["outputs"] ? build["outputs"].join(", ") : "missing (invalid)"
91
+
92
+ puts " #{name}"
93
+ puts " Description: #{build["description"]}" if build["description"]
94
+ puts " Bundler: #{bundler}"
95
+ puts " Outputs: #{outputs}"
96
+ puts ""
97
+ end
98
+ end
99
+
100
+ private
101
+
102
+ def fetch_build_or_raise(config, build_name)
103
+ build = config["builds"][build_name]
104
+ unless build
105
+ available = config["builds"].keys.join(", ")
106
+ raise ArgumentError, "Build '#{build_name}' not found in config file.\n" \
107
+ "Available builds: #{available}\n" \
108
+ "Use 'bin/shakapacker --list-builds' to see all available builds."
109
+ end
110
+ build
111
+ end
112
+
113
+ # Load YAML config file safely with Ruby version compatibility
114
+ # Ruby 3.1+ supports safe_load_file with aliases, older versions need safe_load
115
+ def load_config
116
+ begin
117
+ config = if YAML.respond_to?(:safe_load_file)
118
+ # Ruby 3.1+: Use safe_load_file with aliases enabled
119
+ YAML.safe_load_file(@config_file_path, aliases: true)
120
+ else
121
+ # Ruby 2.7-3.0: Use safe_load with aliases enabled
122
+ YAML.safe_load(
123
+ File.read(@config_file_path),
124
+ permitted_classes: [],
125
+ permitted_symbols: [],
126
+ aliases: true
127
+ )
128
+ end
129
+ rescue ArgumentError
130
+ # Fallback for older Psych versions without aliases support
131
+ config = YAML.safe_load(
132
+ File.read(@config_file_path),
133
+ permitted_classes: [],
134
+ permitted_symbols: []
135
+ )
136
+ end
137
+
138
+ unless config["builds"]&.is_a?(Hash)
139
+ raise ArgumentError, "Config file must contain a 'builds' object"
140
+ end
141
+
142
+ config
143
+ rescue Psych::SyntaxError => e
144
+ raise ArgumentError, "Invalid YAML in config file: #{e.message}"
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,415 @@
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
+ # Regex pattern to detect assets_bundler key in config (only matches uncommented lines)
13
+ ASSETS_BUNDLER_PATTERN = /^[ \t]*assets_bundler:/
14
+
15
+ # Shared dependencies used by both webpack and rspack
16
+ # These should not be removed when switching bundlers
17
+ SHARED_DEPS = {
18
+ dev: %w[],
19
+ prod: %w[webpack-merge]
20
+ }.freeze
21
+
22
+ # Default dependencies for each bundler (package names only, no versions)
23
+ # Note: Excludes independent/optional dependencies like @swc/core, swc-loader (user-configured
24
+ # transpilers)
25
+ DEFAULT_RSPACK_DEPS = {
26
+ dev: %w[@rspack/cli @rspack/plugin-react-refresh],
27
+ prod: %w[@rspack/core rspack-manifest-plugin]
28
+ }.freeze
29
+
30
+ DEFAULT_WEBPACK_DEPS = {
31
+ dev: %w[webpack webpack-cli webpack-dev-server @pmmmwh/react-refresh-webpack-plugin],
32
+ prod: %w[webpack-assets-manifest]
33
+ }.freeze
34
+
35
+ attr_reader :root_path
36
+
37
+ def initialize(root_path = nil)
38
+ @root_path = root_path || (defined?(Rails) ? Rails.root : Pathname.new(Dir.pwd))
39
+ end
40
+
41
+ def current_bundler
42
+ config = load_yaml_config(config_path)
43
+ config.dig("default", "assets_bundler") || "webpack"
44
+ end
45
+
46
+ def switch_to(bundler, install_deps: false, no_uninstall: false)
47
+ unless %w[webpack rspack].include?(bundler)
48
+ raise ArgumentError, "Invalid bundler: #{bundler}. Must be 'webpack' or 'rspack'"
49
+ end
50
+
51
+ current = current_bundler
52
+ config_content = File.read(config_path)
53
+ has_assets_bundler = config_content =~ ASSETS_BUNDLER_PATTERN
54
+
55
+ # Early exit if already using the target bundler
56
+ # For webpack: if current is webpack, we're done (key optional due to default)
57
+ # For rspack: requires explicit key to be present
58
+ already_configured = if bundler == "webpack"
59
+ current == bundler
60
+ else
61
+ current == bundler && has_assets_bundler
62
+ end
63
+
64
+ if already_configured && !install_deps
65
+ puts "✅ Already using #{bundler}"
66
+ return
67
+ end
68
+
69
+ if already_configured && install_deps
70
+ puts "✅ Already using #{bundler} - reinstalling dependencies as requested"
71
+ manage_dependencies(bundler, install_deps, switching: false, no_uninstall: no_uninstall)
72
+ return
73
+ end
74
+
75
+ successfully_updated = update_config(bundler, config_content, has_assets_bundler)
76
+
77
+ # Verify the update was successful (only if update reported success)
78
+ verify_config_update(bundler) if successfully_updated
79
+
80
+ puts "✅ Switched from #{current} to #{bundler}"
81
+ puts ""
82
+ puts "📝 Configuration updated in #{SHAKAPACKER_CONFIG}"
83
+
84
+ manage_dependencies(bundler, install_deps, no_uninstall: no_uninstall)
85
+
86
+ puts ""
87
+ puts "🎯 Next steps:"
88
+ puts " 1. Restart your dev server: bin/dev"
89
+ puts " 2. Verify build works: bin/shakapacker"
90
+ puts ""
91
+ puts "💡 Tip: Both webpack and rspack can coexist in package.json during migration"
92
+ puts " Use --install-deps to automatically manage dependencies, or manage manually"
93
+ puts " Use --no-uninstall to skip removing old bundler packages (faster switching)"
94
+ end
95
+
96
+ def init_config
97
+ if File.exist?(custom_config_path)
98
+ puts "⚠️ #{CUSTOM_DEPS_CONFIG} already exists"
99
+ return
100
+ end
101
+
102
+ config = {
103
+ "rspack" => {
104
+ "devDependencies" => DEFAULT_RSPACK_DEPS[:dev],
105
+ "dependencies" => DEFAULT_RSPACK_DEPS[:prod]
106
+ },
107
+ "webpack" => {
108
+ "devDependencies" => DEFAULT_WEBPACK_DEPS[:dev],
109
+ "dependencies" => DEFAULT_WEBPACK_DEPS[:prod]
110
+ }
111
+ }
112
+
113
+ File.write(custom_config_path, YAML.dump(config))
114
+ puts "✅ Created #{CUSTOM_DEPS_CONFIG}"
115
+ puts ""
116
+ puts "You can now customize the dependencies for each bundler in this file."
117
+ puts "The script will automatically use these custom dependencies when switching bundlers."
118
+ end
119
+
120
+ def show_usage
121
+ current = current_bundler
122
+ puts "Current bundler: #{current}"
123
+ puts ""
124
+ puts "Usage:"
125
+ puts " rake shakapacker:switch_bundler [webpack|rspack] -- [OPTIONS]"
126
+ puts ""
127
+ puts "Options:"
128
+ puts " --install-deps Automatically install/uninstall dependencies"
129
+ puts " --no-uninstall Skip uninstalling old bundler packages"
130
+ puts " --init-config Create #{CUSTOM_DEPS_CONFIG} with default dependencies"
131
+ puts " --help, -h Show this help message"
132
+ puts ""
133
+ puts "Examples:"
134
+ puts " rake shakapacker:switch_bundler rspack -- --install-deps"
135
+ puts " rake shakapacker:switch_bundler webpack -- --install-deps --no-uninstall"
136
+ puts " rake shakapacker:switch_bundler -- --init-config"
137
+ end
138
+
139
+ private
140
+
141
+ def config_path
142
+ root_path.join(SHAKAPACKER_CONFIG)
143
+ end
144
+
145
+ def custom_config_path
146
+ root_path.join(CUSTOM_DEPS_CONFIG)
147
+ end
148
+
149
+ def load_dependencies
150
+ if File.exist?(custom_config_path)
151
+ puts "📝 Using custom dependencies from #{CUSTOM_DEPS_CONFIG}"
152
+ begin
153
+ custom = load_yaml_config(custom_config_path)
154
+ rescue Psych::SyntaxError => e
155
+ puts "❌ Error parsing #{CUSTOM_DEPS_CONFIG}: #{e.message}"
156
+ puts " Please fix the YAML syntax or delete the file to use defaults"
157
+ raise
158
+ end
159
+ rspack_deps = {
160
+ dev: (custom.dig("rspack", "devDependencies") || DEFAULT_RSPACK_DEPS[:dev]) + SHARED_DEPS[:dev],
161
+ prod: (custom.dig("rspack", "dependencies") || DEFAULT_RSPACK_DEPS[:prod]) + SHARED_DEPS[:prod]
162
+ }
163
+ webpack_deps = {
164
+ dev: (custom.dig("webpack", "devDependencies") || DEFAULT_WEBPACK_DEPS[:dev]) + SHARED_DEPS[:dev],
165
+ prod: (custom.dig("webpack", "dependencies") || DEFAULT_WEBPACK_DEPS[:prod]) + SHARED_DEPS[:prod]
166
+ }
167
+ [rspack_deps, webpack_deps]
168
+ else
169
+ rspack_with_shared = {
170
+ dev: DEFAULT_RSPACK_DEPS[:dev] + SHARED_DEPS[:dev],
171
+ prod: DEFAULT_RSPACK_DEPS[:prod] + SHARED_DEPS[:prod]
172
+ }
173
+ webpack_with_shared = {
174
+ dev: DEFAULT_WEBPACK_DEPS[:dev] + SHARED_DEPS[:dev],
175
+ prod: DEFAULT_WEBPACK_DEPS[:prod] + SHARED_DEPS[:prod]
176
+ }
177
+ [rspack_with_shared, webpack_with_shared]
178
+ end
179
+ end
180
+
181
+ def update_config(bundler, content, has_assets_bundler)
182
+ # Check if assets_bundler key exists (only uncommented lines)
183
+ unless has_assets_bundler
184
+ # Track whether we successfully added the key
185
+ added = false
186
+
187
+ # Add assets_bundler after javascript_transpiler if it exists (excluding commented lines)
188
+ if (match = content.match(/^[ \t]*(?![ \t]*#)javascript_transpiler:.*$/))
189
+ indent = match[0][/^[ \t]*/]
190
+ content.sub!(/^([ \t]*(?![ \t]*#)javascript_transpiler:.*$)/, "\\1\n#{assets_bundler_entry(bundler, indent)}")
191
+ added = true
192
+ # Otherwise, add it after source_path if it exists (excluding commented lines)
193
+ elsif (match = content.match(/^[ \t]*(?![ \t]*#)source_path:.*$/))
194
+ indent = match[0][/^[ \t]*/]
195
+ content.sub!(/^([ \t]*(?![ \t]*#)source_path:.*$)/, "\\1\n#{assets_bundler_entry(bundler, indent)}")
196
+ added = true
197
+ # Add it after default: &default if it exists
198
+ elsif content.match?(/^default:[ \t]*&default[ \t]*$/)
199
+ # Use default 2-space indentation for this case
200
+ content.sub!(/^(default:[ \t]*&default[ \t]*)$/, "\\1\n#{assets_bundler_entry(bundler, ' ')}")
201
+ added = true
202
+ # Fallback: add after "default:" with proper indentation detection (handles blank lines)
203
+ elsif (match = content.match(/^default:\s*\n\s*([ \t]+)/m))
204
+ # Extract indentation from first indented line after "default:"
205
+ indent = match[1]
206
+ content.sub!(/^(default:\s*)$/, "\\1\n#{assets_bundler_entry(bundler, indent)}")
207
+ added = true
208
+ end
209
+
210
+ unless added
211
+ puts "⚠️ Warning: Could not find appropriate location for assets_bundler in config"
212
+ puts " Please add 'assets_bundler: #{bundler}' to the default section manually"
213
+ end
214
+ else
215
+ # Replace existing assets_bundler value (handles spaces, tabs, and various quote styles)
216
+ # Only matches uncommented lines
217
+ content.gsub!(/^([ \t]*)(?![ \t]*#)(assets_bundler:[ \t]*['"]?)(webpack|rspack)(['"]?)/, "\\1\\2#{bundler}\\4")
218
+ added = true
219
+ end
220
+
221
+ # Update javascript_transpiler recommendation for rspack
222
+ # Only update if not already set to swc and only on uncommented lines
223
+ if bundler == "rspack" && content !~ /^[ \t]*(?![ \t]*#)javascript_transpiler:[ \t]*['"]?swc['"]?/
224
+ content.gsub!(/^([ \t]*(?![ \t]*#)javascript_transpiler:[ \t]*['"]?)(\w+)(['"]?)/, '\1swc\3')
225
+ end
226
+
227
+ File.write(config_path, content)
228
+ added
229
+ end
230
+
231
+ # Verify that the config was updated successfully
232
+ def verify_config_update(bundler)
233
+ config = load_yaml_config(config_path)
234
+ actual_bundler = config.dig("default", "assets_bundler")
235
+
236
+ if actual_bundler != bundler
237
+ raise "Config update verification failed: expected assets_bundler to be '#{bundler}', but got '#{actual_bundler}'"
238
+ end
239
+ rescue Psych::SyntaxError => e
240
+ raise "Config update generated invalid YAML: #{e.message}"
241
+ end
242
+
243
+ # Generate the assets_bundler YAML entry with proper indentation
244
+ # @param bundler [String] The bundler name ('webpack' or 'rspack')
245
+ # @param indent [String] The indentation string to use (e.g., ' ' or '\t')
246
+ # @return [String] The formatted YAML entry
247
+ def assets_bundler_entry(bundler, indent)
248
+ "\n#{indent}# Select assets bundler to use\n#{indent}# Available options: 'webpack' (default) or 'rspack'\n#{indent}assets_bundler: \"#{bundler}\""
249
+ end
250
+
251
+ def manage_dependencies(bundler, install_deps, switching: true, no_uninstall: false)
252
+ rspack_deps, webpack_deps = load_dependencies
253
+ deps_to_install = bundler == "rspack" ? rspack_deps : webpack_deps
254
+ old_bundler_deps = bundler == "rspack" ? webpack_deps : rspack_deps
255
+
256
+ # Remove shared dependencies from removal list
257
+ deps_to_remove = {
258
+ dev: old_bundler_deps[:dev] - SHARED_DEPS[:dev],
259
+ prod: old_bundler_deps[:prod] - SHARED_DEPS[:prod]
260
+ }
261
+
262
+ if install_deps
263
+ puts ""
264
+ puts "📦 Managing dependencies..."
265
+ puts ""
266
+
267
+ # Show what will be removed (only when switching and not no_uninstall)
268
+ if switching && !no_uninstall && (!deps_to_remove[:dev].empty? || !deps_to_remove[:prod].empty?)
269
+ puts " 🗑️ Removing:"
270
+ deps_to_remove[:dev].each { |dep| puts " - #{dep} (dev)" }
271
+ deps_to_remove[:prod].each { |dep| puts " - #{dep} (prod)" }
272
+ puts ""
273
+ elsif switching && no_uninstall
274
+ puts " ⏭️ Skipping uninstall (--no-uninstall)"
275
+ puts ""
276
+ end
277
+
278
+ # Show what will be installed
279
+ if !deps_to_install[:dev].empty? || !deps_to_install[:prod].empty?
280
+ puts " 📦 Installing:"
281
+ deps_to_install[:dev].each { |dep| puts " - #{dep} (dev)" }
282
+ deps_to_install[:prod].each { |dep| puts " - #{dep} (prod)" }
283
+ puts ""
284
+ end
285
+
286
+ # Remove old bundler dependencies (only when switching and not no_uninstall)
287
+ if switching && !no_uninstall
288
+ remove_dependencies(deps_to_remove)
289
+ end
290
+
291
+ # Install new bundler dependencies
292
+ install_dependencies(deps_to_install)
293
+
294
+ puts " ✅ Dependencies updated"
295
+ else
296
+ print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
297
+ end
298
+ end
299
+
300
+ def remove_dependencies(deps)
301
+ package_json = get_package_json
302
+
303
+ # Combine dev and prod dependencies into a single list for removal
304
+ # Package managers remove packages from both dependencies and devDependencies sections if present
305
+ all_deps = deps[:dev] + deps[:prod]
306
+
307
+ unless all_deps.empty?
308
+ unless package_json.manager.remove(all_deps)
309
+ puts " ⚠️ Warning: Failed to uninstall some dependencies"
310
+ end
311
+ end
312
+ end
313
+
314
+ def install_dependencies(deps)
315
+ package_json = get_package_json
316
+
317
+ unless deps[:dev].empty?
318
+ unless package_json.manager.add(deps[:dev], type: :dev)
319
+ puts "❌ Failed to install dev dependencies"
320
+ raise "Failed to install dev dependencies"
321
+ end
322
+ end
323
+
324
+ unless deps[:prod].empty?
325
+ unless package_json.manager.add(deps[:prod], type: :production)
326
+ puts "❌ Failed to install prod dependencies"
327
+ raise "Failed to install prod dependencies"
328
+ end
329
+ end
330
+
331
+ # Run a full install to ensure optional dependencies (like native bindings) are properly resolved
332
+ # This is especially important for packages like @rspack/core that use platform-specific native modules
333
+ unless package_json.manager.install
334
+ puts "❌ Failed to run full install to resolve optional dependencies"
335
+ raise "Failed to run full install"
336
+ end
337
+ end
338
+
339
+ def get_package_json
340
+ require "package_json"
341
+ PackageJson.read(root_path)
342
+ end
343
+
344
+ def print_manual_dependency_instructions(bundler, deps_to_install, deps_to_remove)
345
+ puts ""
346
+ puts "⚠️ Dependencies not automatically installed (use --install-deps to auto-install)"
347
+ puts ""
348
+
349
+ package_manager = detect_package_manager
350
+ target_name = bundler == "rspack" ? "rspack" : "webpack"
351
+ old_name = bundler == "rspack" ? "webpack" : "rspack"
352
+
353
+ puts "📦 To install #{target_name} dependencies, run:"
354
+ print_install_commands(package_manager, deps_to_install)
355
+ puts ""
356
+ puts "🗑️ To remove #{old_name} dependencies, run:"
357
+ print_uninstall_commands(package_manager, deps_to_remove)
358
+ end
359
+
360
+ def detect_package_manager
361
+ get_package_json.manager.binary
362
+ rescue StandardError
363
+ "npm" # Fallback to npm if detection fails
364
+ end
365
+
366
+ def print_install_commands(package_manager, deps)
367
+ case package_manager
368
+ when "yarn"
369
+ puts " yarn add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
370
+ puts " yarn add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
371
+ when "pnpm"
372
+ puts " pnpm add -D #{deps[:dev].join(' ')}" unless deps[:dev].empty?
373
+ puts " pnpm add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
374
+ when "bun"
375
+ puts " bun add --dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
376
+ puts " bun add #{deps[:prod].join(' ')}" unless deps[:prod].empty?
377
+ else # npm
378
+ puts " npm install --save-dev #{deps[:dev].join(' ')}" unless deps[:dev].empty?
379
+ puts " npm install --save #{deps[:prod].join(' ')}" unless deps[:prod].empty?
380
+ end
381
+ end
382
+
383
+ def print_uninstall_commands(package_manager, deps)
384
+ case package_manager
385
+ when "yarn"
386
+ puts " yarn remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
387
+ puts " yarn remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
388
+ when "pnpm"
389
+ puts " pnpm remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
390
+ puts " pnpm remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
391
+ when "bun"
392
+ puts " bun remove #{deps[:dev].join(' ')}" unless deps[:dev].empty?
393
+ puts " bun remove #{deps[:prod].join(' ')}" unless deps[:prod].empty?
394
+ else # npm
395
+ puts " npm uninstall #{deps[:dev].join(' ')}" unless deps[:dev].empty?
396
+ puts " npm uninstall #{deps[:prod].join(' ')}" unless deps[:prod].empty?
397
+ end
398
+ end
399
+
400
+ # Load YAML config file with Ruby version compatibility
401
+ # Ruby 3.1+ supports aliases: keyword, older versions need YAML.safe_load
402
+ def load_yaml_config(path)
403
+ if YAML.respond_to?(:unsafe_load_file)
404
+ # Ruby 3.1+: Use unsafe_load_file to support aliases/anchors
405
+ YAML.unsafe_load_file(path)
406
+ else
407
+ # Ruby 2.7-3.0: Use safe_load with aliases enabled
408
+ YAML.safe_load(File.read(path), permitted_classes: [], permitted_symbols: [], aliases: true)
409
+ end
410
+ rescue ArgumentError
411
+ # Ruby 2.7 doesn't support aliases keyword - fall back to YAML.load
412
+ YAML.load(File.read(path)) # rubocop:disable Security/YAMLLoad
413
+ end
414
+ end
415
+ end
@@ -1,5 +1,6 @@
1
1
  require "open3"
2
2
  require "fileutils"
3
+ require "shellwords"
3
4
 
4
5
  require_relative "compiler_strategy"
5
6
 
@@ -26,6 +27,7 @@ class Shakapacker::Compiler
26
27
  true
27
28
  else
28
29
  acquire_ipc_lock do
30
+ run_precompile_hook if should_run_precompile_hook?
29
31
  run_webpack.tap do |success|
30
32
  after_compile_hook
31
33
  end
@@ -78,6 +80,91 @@ class Shakapacker::Compiler
78
80
  /ruby/.match?(first_line) ? RbConfig.ruby : ""
79
81
  end
80
82
 
83
+ def should_run_precompile_hook?
84
+ return false unless config.precompile_hook
85
+ return false if ENV["SHAKAPACKER_SKIP_PRECOMPILE_HOOK"] == "true"
86
+
87
+ true
88
+ end
89
+
90
+ def run_precompile_hook
91
+ hook_command = config.precompile_hook
92
+ hook_spec = validate_precompile_hook(hook_command)
93
+
94
+ logger.info "Running precompile hook: #{hook_command}"
95
+
96
+ runtime_env = webpack_env.merge(hook_spec[:env])
97
+ stdout, stderr, status = Open3.capture3(
98
+ runtime_env,
99
+ hook_spec[:executable],
100
+ *hook_spec[:args],
101
+ chdir: File.expand_path(config.root_path)
102
+ )
103
+
104
+ if status.success?
105
+ logger.info "Precompile hook completed successfully"
106
+ logger.info stdout unless stdout.empty?
107
+ logger.warn stderr unless stderr.empty?
108
+ else
109
+ non_empty_streams = [stdout, stderr].delete_if(&:empty?)
110
+ logger.error "\nPRECOMPILE HOOK FAILED:\nEXIT STATUS: #{status.exitstatus}\nCOMMAND: #{hook_command}\nOUTPUTS:\n#{non_empty_streams.join("\n\n")}"
111
+ logger.error "\nTo fix this:"
112
+ logger.error " 1. Check that the hook script exists and is executable"
113
+ logger.error " 2. Test the hook command manually: #{hook_command}"
114
+ logger.error " 3. Review the error output above for details"
115
+ logger.error " 4. You can disable the hook temporarily by commenting out 'precompile_hook' in shakapacker.yml"
116
+ raise "Precompile hook '#{hook_command}' failed with exit status #{status.exitstatus}"
117
+ end
118
+ end
119
+
120
+ def validate_precompile_hook(hook_command)
121
+ hook_tokens = begin
122
+ Shellwords.shellsplit(hook_command)
123
+ rescue ArgumentError => e
124
+ raise "Shakapacker configuration error: Invalid precompile_hook command syntax: #{e.message}. Check for unmatched quotes in: #{hook_command}"
125
+ end
126
+
127
+ env_assignments = {}
128
+ while hook_tokens.first&.match?(/\A[A-Za-z_][A-Za-z0-9_]*=/)
129
+ key, value = hook_tokens.shift.split("=", 2)
130
+ env_assignments[key] = value
131
+ end
132
+
133
+ executable = hook_tokens.shift
134
+ if executable.nil? || executable.empty?
135
+ raise "Shakapacker configuration error: precompile_hook must include an executable command. Got: #{hook_command}"
136
+ end
137
+
138
+ executable_path = config.root_path.join(executable)
139
+
140
+ # Security: Resolve symlinks and verify the hook points to a file within the project
141
+ # This prevents symlink bypass attacks and path traversal attacks
142
+ begin
143
+ resolved_path = executable_path.realpath
144
+ resolved_root = config.root_path.realpath
145
+ rescue Errno::ENOENT
146
+ # If file doesn't exist, use cleanpath for basic validation
147
+ resolved_path = executable_path.cleanpath
148
+ resolved_root = config.root_path.cleanpath
149
+ end
150
+
151
+ # Verify path is within project root with proper separator check
152
+ # Using File::SEPARATOR prevents partial path matches (e.g., /project vs /project-evil)
153
+ unless resolved_path.to_s.start_with?(resolved_root.to_s + File::SEPARATOR)
154
+ raise "Security Error: precompile_hook must reference a script within the project root. " \
155
+ "Got: #{hook_command} (resolved to: #{resolved_path})"
156
+ end
157
+
158
+ # Warn if the executable doesn't exist within the project
159
+ unless File.exist?(executable_path)
160
+ logger.warn "⚠️ Warning: precompile_hook executable not found: #{executable_path}"
161
+ logger.warn " The hook command is configured but the script does not exist within the project root."
162
+ logger.warn " Please ensure the script exists or remove 'precompile_hook' from your shakapacker.yml configuration."
163
+ end
164
+
165
+ { env: env_assignments, executable: executable, args: hook_tokens }
166
+ end
167
+
81
168
  def run_webpack
82
169
  logger.info "Compiling..."
83
170