rspack_rails 0.2.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b7f84e6f9280bcf6d3fbd54f2a3a267a817e7c333d4c89a70c8e171b477f3e85
4
- data.tar.gz: '09e2d5eb77331777f33a1b58478ba1bac7774ffe83308b0f97236653fb80f534'
3
+ metadata.gz: d5c0f485458181960a59600c16b354bbb260d5856d37200ffaf416e4d6c88222
4
+ data.tar.gz: a06bf0ec56b0850342ea39c4d461f4d165e8b3c1f046c7c41d6149b5412c5fbe
5
5
  SHA512:
6
- metadata.gz: 79928a7186a0b752296a5d82cf5e570ce87730a24ce2c1c515c4490be4585fe512790b68b4bdef81f9a76c608a73a615d4db75d7891889092f1a8a56a7a76e66
7
- data.tar.gz: 11660d1b0a381e43a789b8b5b2b92593784755cbfda2d00f32f1646b784dd39d959329fa2845febf900e4e6af38e4879c816832ecb5f2c944e1f8d6241ffbe47
6
+ metadata.gz: 70886ccbe82932f10548639cb3190b4a06ad032fa854c5677c87553c93d4a32feb4a9968a5b72881fa5cfb94833ca02994168a541771c95a33222d51422677f4
7
+ data.tar.gz: 989663562bc6cd2e8a72ef05872319117c5f21e9b43697fc57874ea6c725370a86118ec3e01bd950f854dddbc57d48a8e9f10b675f2ef0686c1126df93573203
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.4.0] - 2026-11-13
4
+
5
+ - `rspack:migrate`: moves an importmap-rails or jsbundling-rails (esbuild/webpack/rollup) app to rspack. Requires a clean git tree (or `FORCE=1`)
6
+ - `rspack.config.mjs` resolves bare imports from `app/javascript` (`resolve.modules`), so importmap-style `import "controllers/application"` keeps working
7
+ - `rspack:install:stimulus` also rewrites a bare `import "controllers"`
8
+ - Installers stop when a package install fails instead of carrying on
9
+
10
+ ## [0.3.0] - 2026-11-13
11
+
12
+ - Multiple entry points: every `app/javascript/entrypoints/*.{js,ts}` becomes a bundle named after the file; `application.ts` works as the main entry
13
+ - `rspack:doctor` lists entry points and reports any that are not built
14
+ - Fix: the dev server no longer writes `*.hot-update.js` files into `app/assets/builds`
15
+ - Existing apps: re-run `bin/rails rspack:install` and accept the new `rspack.config.mjs`
16
+
3
17
  ## [0.2.0] - 2026-11-13
4
18
 
5
19
  - CSS imported from JavaScript is extracted to `app/assets/builds/<entry>.bundle.css` and hot-reloaded under `rspack serve`; `*.module.css` are CSS modules
data/README.md CHANGED
@@ -1,8 +1,24 @@
1
1
  # rspack_rails
2
2
 
3
- Bundle your Rails app's JavaScript with [Rspack](https://rspack.rs), the Rust-powered, webpack-compatible bundler.
3
+ [![Gem Version](https://img.shields.io/gem/v/rspack_rails)](https://rubygems.org/gems/rspack_rails)
4
+ [![CI](https://github.com/tungnt1203/rspack_rails/actions/workflows/main.yml/badge.svg)](https://github.com/tungnt1203/rspack_rails/actions/workflows/main.yml)
4
5
 
5
- rspack_rails follows the `jsbundling-rails` model: rspack writes bundles to `app/assets/builds`, and Propshaft (or Sprockets) serves and fingerprints them.
6
+ Bundle your Rails app's JavaScript and CSS with [Rspack](https://rspack.rs), the Rust-powered, webpack-compatible bundler, and get **hot module replacement** in development.
7
+
8
+ ![Editing CSS and a Stimulus controller while the page keeps its state](docs/hmr.gif)
9
+
10
+ *Above: CSS and a Stimulus controller are edited while the page is open. Both update in place, and the counter keeps its value because the page never reloads.*
11
+
12
+ - **One command setup**: `bin/rails rspack:install`, then `bin/dev`
13
+ - **HMR for JavaScript, CSS and Stimulus controllers**, with an automatic fallback to the asset pipeline when the dev server is not running
14
+ - **Stimulus autoloading**: drop a `*_controller.js` (or `.ts`) in `app/javascript/controllers`, no manifest to maintain
15
+ - **TypeScript and CSS modules** out of the box, through Rspack's built-in SWC and native CSS
16
+ - **Multiple entry points** from `app/javascript/entrypoints/`
17
+ - **`rspack:migrate`** moves an importmap or jsbundling app over in one command
18
+ - **`rspack:doctor`** checks Node.js, packages, config and layout, and says what to fix
19
+ - Works with **Propshaft and Sprockets**; CI runs a real app on Rails 7.2 and 8.1
20
+
21
+ rspack_rails follows the `jsbundling-rails` model: rspack writes bundles to `app/assets/builds`, and Propshaft (or Sprockets) serves and fingerprints them in production.
6
22
 
7
23
  ## Installation
8
24
 
@@ -19,6 +35,28 @@ The installer:
19
35
  - adds a `js:` process running the dev server to `Procfile.dev` and sets up `bin/dev`
20
36
  - installs `@rspack/cli`, `@rspack/core` and `@rspack/dev-server` with your package manager (bun, yarn, pnpm or npm, detected from the lockfile)
21
37
 
38
+ ## Migrating from importmap or jsbundling
39
+
40
+ ```bash
41
+ git commit -am "Before rspack" # rspack:migrate refuses to run on a dirty tree (FORCE=1 to override)
42
+ bin/rails rspack:migrate
43
+ git diff # review
44
+ bin/dev
45
+ ```
46
+
47
+ **From importmap-rails**
48
+ - Every `pin` in `config/importmap.rb` becomes an npm dependency. Versions recorded by importmap (`# @1.2.3` or jspm URLs) are kept. Gem-served pins such as Turbo install the latest npm release.
49
+ - `javascript_importmap_tags` is replaced with `rspack_javascript_tag`, and `import "controllers"` with the Stimulus autoloader.
50
+ - `config/importmap.rb`, `bin/importmap`, vendored packages, the `importmap-rails` gem, `stale_when_importmap_changes` and the `bin/importmap audit` CI step are removed.
51
+ - Bare imports of app code (`import "controllers/application"`) keep working through `resolve.modules`.
52
+
53
+ **From jsbundling-rails** (esbuild, webpack, rollup)
54
+ - The `jsbundling-rails` gem, the old bundler package and its config file are removed. `Procfile.dev` runs the rspack dev server.
55
+ - `javascript_include_tag "application"` becomes `rspack_javascript_tag`, and the generated `controllers/index.js` gives way to the autoloader.
56
+ - Extra entry files in the root of `app/javascript/` are listed so you can move them to `app/javascript/entrypoints/`.
57
+
58
+ The task finishes by listing any remaining lines that mention importmap or jsbundling. Shakapacker/Webpacker apps are not supported yet.
59
+
22
60
  ## Usage
23
61
 
24
62
  Run `bin/dev` to start Rails and the rspack dev server (with hot module replacement) together.
@@ -63,6 +101,23 @@ Under `rspack serve`, editing a controller or adding a new one hot-swaps it in t
63
101
 
64
102
  If `application.js` imported `./controllers` (the stimulus-rails index), the installer switches it to `./controllers/autoload`. stimulus-rails may still rewrite `controllers/index.js` when you run `bin/rails generate stimulus`, but nothing imports that file any more.
65
103
 
104
+ ### Multiple entry points
105
+
106
+ Besides `app/javascript/application.js` (or `.ts`), every file in `app/javascript/entrypoints/` becomes its own bundle, named after the file:
107
+
108
+ ```
109
+ app/javascript/application.js -> application.js
110
+ app/javascript/entrypoints/admin.ts -> admin.js
111
+ app/javascript/entrypoints/checkout.js -> checkout.js (+ checkout.bundle.css if it imports CSS)
112
+ ```
113
+
114
+ ```erb
115
+ <%# app/views/layouts/admin.html.erb %>
116
+ <%= rspack_javascript_tag "admin" %>
117
+ ```
118
+
119
+ Each bundle is self-contained, so load one entry per page (for example one per layout). Restart the dev server after adding a new entry point.
120
+
66
121
  ### CSS
67
122
 
68
123
  Import CSS from your JavaScript and rspack extracts it to `app/assets/builds/<entry>.bundle.css`:
@@ -95,6 +150,7 @@ npm run typecheck
95
150
  |---|---|
96
151
  | `bin/rails rspack:install:stimulus` | Sets up Stimulus with controller autoloading |
97
152
  | `bin/rails rspack:install:typescript` | Adds tsconfig.json, typescript and a `typecheck` script |
153
+ | `bin/rails rspack:migrate` | Moves an importmap or jsbundling app to rspack |
98
154
  | `bin/rails rspack:doctor` | Checks Node.js, packages, config, layout, Procfile and dev server |
99
155
  | `bin/rails rspack:build` | Installs JS dependencies, then runs `build` |
100
156
  | `bin/rails rspack:clobber` | Removes rspack output from `app/assets/builds` |
@@ -124,6 +180,9 @@ The required Node.js version comes from the `engines` field of the installed `@r
124
180
  ```bash
125
181
  bin/setup
126
182
  bundle exec rake # specs + rubocop
183
+ bin/integration # new Rails app + every installer, build, doctor and precompile
184
+ bin/integration "~> 7.2.0" # against a specific Rails version
185
+ bin/integration-migrate importmap # rails new + rspack:migrate (or: jsbundling)
127
186
  ```
128
187
 
129
188
  ## License
@@ -68,6 +68,6 @@ unless File.exist?("bin/dev") && File.read("bin/dev").include?("Procfile.dev")
68
68
  end
69
69
 
70
70
  say "Installing @rspack/cli, @rspack/core and @rspack/dev-server"
71
- run pm.add_dev_command("@rspack/cli", "@rspack/core", "@rspack/dev-server")
71
+ run pm.add_dev_command("@rspack/cli", "@rspack/core", "@rspack/dev-server"), abort_on_failure: true
72
72
 
73
73
  say "rspack_rails installed. Run bin/dev to start developing.", :green
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Rails application template run by `bin/rails rspack:migrate`.
4
+ # Moves an app from importmap-rails or jsbundling-rails to rspack_rails.
5
+ require "json"
6
+
7
+ migration = RspackRails::Migration.new(Rails.root)
8
+ pm = RspackRails::PackageManager.detect(Rails.root)
9
+ layout = "app/views/layouts/application.html.erb"
10
+ stimulus = File.exist?("app/javascript/controllers/application.js")
11
+
12
+ def remove_gem(name)
13
+ gem_line = /^[ \t]*gem[ \t]+["']#{Regexp.escape(name)}["'].*\n/
14
+ gsub_file "Gemfile", /^[ \t]*#.*\n(?=#{gem_line.source})/, "" # the comment describing it
15
+ gsub_file "Gemfile", gem_line, ""
16
+ end
17
+
18
+ case migration.source
19
+ when :rspack
20
+ say "This app already uses rspack_rails. Run `bin/rails rspack:doctor` to check it.", :green
21
+ exit
22
+ when :shakapacker
23
+ say "Shakapacker/Webpacker apps are not supported by rspack:migrate yet.", :red
24
+ exit 1
25
+ when :none
26
+ say "No importmap-rails or jsbundling-rails setup found. Running rspack:install instead."
27
+ when :importmap
28
+ say "Migrating from importmap-rails"
29
+ packages = migration.npm_install_args
30
+ vendored = migration.vendored_files
31
+
32
+ gsub_file layout, /^\s*<%= javascript_importmap_tags.*%>\n/, "" if File.exist?(layout)
33
+ if File.exist?("app/assets/config/manifest.js")
34
+ gsub_file "app/assets/config/manifest.js", %r{^//= link_tree \.\./\.\./javascript .js\n}, ""
35
+ gsub_file "app/assets/config/manifest.js", %r{^//= link_tree \.\./\.\./\.\./vendor/javascript .js\n}, ""
36
+ end
37
+ if File.exist?("app/javascript/application.js")
38
+ gsub_file "app/javascript/application.js", %r{^// Configure your import map in config/importmap.rb.*\n}, ""
39
+ end
40
+ Dir.glob("app/controllers/**/*.rb").each do |controller|
41
+ next unless File.read(controller).include?("stale_when_importmap_changes")
42
+
43
+ gsub_file controller, /^[ \t]*# Changes to the importmap will invalidate the etag.*\n/, ""
44
+ gsub_file controller, /^[ \t]*stale_when_importmap_changes.*\n/, ""
45
+ end
46
+ gsub_file "config/ci.rb", %r{^[ \t]*step .*bin/importmap audit.*\n}, "" if File.exist?("config/ci.rb")
47
+ remove_file "config/importmap.rb"
48
+ remove_file "bin/importmap"
49
+ vendored.each { |path| remove_file path }
50
+ remove_gem "importmap-rails"
51
+ when :jsbundling
52
+ say "Migrating from jsbundling-rails"
53
+ bundlers = migration.bundler_packages
54
+ migration.bundler_configs.each { |path| remove_file path }
55
+ gsub_file "Procfile.dev", /^js: .*\n/, "js: #{pm.run_command("dev")}\n" if File.exist?("Procfile.dev")
56
+ remove_gem "jsbundling-rails"
57
+ end
58
+
59
+ apply "#{__dir__}/install.rb"
60
+
61
+ if migration.source == :importmap && packages.any?
62
+ say "Installing packages that were pinned in config/importmap.rb: #{packages.join(", ")}"
63
+ run pm.add_command(*packages), abort_on_failure: true
64
+ end
65
+
66
+ if migration.source == :jsbundling && bundlers.any?
67
+ say "Removing the previous bundler: #{bundlers.join(", ")}"
68
+ package = JSON.parse(File.read("package.json"))
69
+ bundlers.each do |name|
70
+ package["dependencies"]&.delete(name)
71
+ package["devDependencies"]&.delete(name)
72
+ end
73
+ File.write("package.json", "#{JSON.pretty_generate(package)}\n")
74
+ end
75
+
76
+ apply "#{__dir__}/stimulus.rb" if stimulus
77
+
78
+ controllers_index = "app/javascript/controllers/index.js"
79
+ if migration.source == :importmap && File.exist?(controllers_index) &&
80
+ File.read(controllers_index).include?("@hotwired/stimulus-loading")
81
+ remove_file controllers_index # replaced by controllers/autoload.js
82
+ end
83
+
84
+ if migration.source == :jsbundling
85
+ migration.extra_root_entries.each do |path|
86
+ say "#{path} was an entry point under jsbundling. Move it to app/javascript/entrypoints/ " \
87
+ "so rspack builds it.", :yellow
88
+ end
89
+ end
90
+
91
+ leftovers = Dir.glob("{app,config,lib}/**/*.{rb,erb,js,ts,yml}").flat_map do |path|
92
+ File.readlines(path).each_with_index.filter_map do |line, index|
93
+ next if line.strip.start_with?("#", "//", "<%#")
94
+
95
+ "#{path}:#{index + 1}: #{line.strip}" if line.match?(/importmap|jsbundling/i)
96
+ end
97
+ end
98
+ if leftovers.any?
99
+ say "These lines still mention importmap/jsbundling and may need a manual change:", :yellow
100
+ leftovers.each { |line| say " #{line}", :yellow }
101
+ end
102
+
103
+ say "Migration finished. Review the changes with `git diff`, then run `bin/rails rspack:doctor`.", :green
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { fileURLToPath } from "node:url";
3
4
  import { defineConfig } from "@rspack/cli";
@@ -12,51 +13,69 @@ const isProduction =
12
13
  const host = process.env.RSPACK_DEV_SERVER_HOST || "localhost";
13
14
  const port = Number(process.env.RSPACK_DEV_SERVER_PORT || 3036);
14
15
 
16
+ // Entries: app/javascript/application.{js,ts} plus every file in
17
+ // app/javascript/entrypoints/, named after the file (admin.js -> "admin").
18
+ // Restart the dev server after adding an entrypoint.
19
+ const entry = {};
20
+ for (const ext of ["js", "ts"]) {
21
+ const file = `./app/javascript/application.${ext}`;
22
+ if (!entry.application && fs.existsSync(path.resolve(root, file))) entry.application = file;
23
+ }
24
+ const entrypointsDir = path.resolve(root, "app/javascript/entrypoints");
25
+ if (fs.existsSync(entrypointsDir)) {
26
+ for (const file of fs.readdirSync(entrypointsDir).sort()) {
27
+ const match = file.match(/^(.+)\.(js|ts)$/);
28
+ if (match) entry[match[1]] = `./app/javascript/entrypoints/${file}`;
29
+ }
30
+ }
31
+
15
32
  export default defineConfig({
16
33
  mode: isProduction ? "production" : "development",
17
34
  devtool: "source-map",
18
- entry: {
19
- application: "./app/javascript/application.js",
20
- },
35
+ entry,
21
36
  output: {
22
37
  path: path.resolve(root, "app/assets/builds"),
23
38
  filename: "[name].js",
24
39
  // ".digested" tells Propshaft the file is already fingerprinted.
25
- chunkFilename: "[name]-[contenthash].digested.js",
26
- // CSS imported from JavaScript. ".bundle" avoids clashing with
27
- // app/assets/stylesheets/application.css in Propshaft.
28
- cssFilename: "[name].bundle.css",
29
- cssChunkFilename: "[name]-[contenthash].digested.css",
40
+ chunkFilename: "[name]-[contenthash].digested.js",
41
+ // CSS imported from JavaScript. ".bundle" avoids clashing with
42
+ // app/assets/stylesheets/application.css in Propshaft.
43
+ cssFilename: "[name].bundle.css",
44
+ cssChunkFilename: "[name]-[contenthash].digested.css",
30
45
  // Under `rspack serve`, chunks and hot updates load from the dev server.
31
46
  publicPath: isDevServer ? `http://${host}:${port}/` : "/assets/",
32
47
  },
33
- resolve: {
34
- extensions: ["...", ".ts"],
35
- },
36
- module: {
37
- rules: [
38
- {
39
- // Transpile TypeScript with Rspack's built-in SWC (no type checking).
40
- test: /\.ts$/,
41
- exclude: /node_modules/,
42
- loader: "builtin:swc-loader",
43
- options: { jsc: { parser: { syntax: "typescript" } } },
44
- type: "javascript/auto",
45
- },
46
- {
47
- // Native CSS; *.module.css files become CSS modules.
48
- test: /\.css$/,
49
- type: "css/auto",
50
- },
51
- ],
52
- },
48
+ resolve: {
49
+ extensions: ["...", ".ts"],
50
+ // Bare imports of app code work as they did with importmap
51
+ // (import "controllers/application", import "lib/utils").
52
+ modules: ["node_modules", path.resolve(root, "app/javascript")],
53
+ },
54
+ module: {
55
+ rules: [
56
+ {
57
+ // Transpile TypeScript with Rspack's built-in SWC (no type checking).
58
+ test: /\.ts$/,
59
+ exclude: /node_modules/,
60
+ loader: "builtin:swc-loader",
61
+ options: { jsc: { parser: { syntax: "typescript" } } },
62
+ type: "javascript/auto",
63
+ },
64
+ {
65
+ // Native CSS; *.module.css files become CSS modules.
66
+ test: /\.css$/,
67
+ type: "css/auto",
68
+ },
69
+ ],
70
+ },
53
71
  devServer: {
54
72
  host,
55
73
  port,
56
74
  hot: true,
57
75
  // Keep app/assets/builds current while the dev server runs, so Propshaft
58
- // (and `stylesheet_link_tag :app`) never serve a stale bundle.
59
- devMiddleware: { writeToDisk: true },
76
+ // (and `stylesheet_link_tag :app`) never serve a stale bundle. Hot update
77
+ // files stay in memory so they never reach public/assets.
78
+ devMiddleware: { writeToDisk: (file) => !/\.hot-update\./.test(file) },
60
79
  allowedHosts: "all",
61
80
  headers: { "Access-Control-Allow-Origin": "*" },
62
81
  client: {
@@ -16,9 +16,9 @@ end
16
16
 
17
17
  if !File.exist?(entry)
18
18
  create_file entry, %(import "./controllers/autoload"\n)
19
- elsif File.read(entry).match?(%r{^import ["']\./controllers(/index(\.js)?)?["'];?$})
20
- say "Replacing the controllers/index.js import with controllers/autoload"
21
- gsub_file entry, %r{^import ["']\./controllers(/index(\.js)?)?["'];?$}, %(import "./controllers/autoload")
19
+ elsif File.read(entry).match?(%r{^import ["'](?:\./)?controllers(/index(\.js)?)?["'];?$})
20
+ say "Replacing the controllers import with controllers/autoload"
21
+ gsub_file entry, %r{^import ["'](?:\./)?controllers(/index(\.js)?)?["'];?$}, %(import "./controllers/autoload")
22
22
  elsif !File.read(entry).include?("./controllers/autoload")
23
23
  append_to_file entry, %(import "./controllers/autoload"\n)
24
24
  end
@@ -28,6 +28,6 @@ if File.exist?("app/javascript/controllers/index.js")
28
28
  end
29
29
 
30
30
  say "Installing @hotwired/stimulus"
31
- run pm.add_command("@hotwired/stimulus")
31
+ run pm.add_command("@hotwired/stimulus"), abort_on_failure: true
32
32
 
33
33
  say "Stimulus installed. Controllers in app/javascript/controllers are registered automatically.", :green
@@ -14,6 +14,6 @@ package = JSON.parse(File.read("package.json"))
14
14
  File.write("package.json", "#{JSON.pretty_generate(package)}\n")
15
15
 
16
16
  say "Installing typescript"
17
- run pm.add_dev_command("typescript")
17
+ run pm.add_dev_command("typescript"), abort_on_failure: true
18
18
 
19
19
  say "TypeScript ready. Rspack compiles .ts files; run `#{pm.run_command("typecheck")}` to type-check.", :green
@@ -4,6 +4,7 @@ require "json"
4
4
  require "open3"
5
5
  require "pathname"
6
6
  require_relative "node_version"
7
+ require_relative "entrypoints"
7
8
 
8
9
  module RspackRails
9
10
  # Inspects a Rails app's rspack setup and reports what is missing.
@@ -91,17 +92,17 @@ module RspackRails
91
92
  end
92
93
 
93
94
  def entry_check
94
- return Check.new(:ok, "Entry point", "app/javascript/application.js") if exist?("app/javascript/application.js")
95
+ return Check.new(:ok, "Entry points", entrypoints.keys.join(", ")) if entrypoints.any?
95
96
 
96
- Check.new(:error, "Entry point", "app/javascript/application.js is missing",
97
- "Create it, or point `entry` in rspack.config.mjs at your entry file")
97
+ Check.new(:error, "Entry points", "none found",
98
+ "Create app/javascript/application.js or a file in app/javascript/entrypoints/")
98
99
  end
99
100
 
100
101
  def build_check
101
- built = @root.join("app/assets/builds/application.js")
102
- return Check.new(:ok, "Build output", "app/assets/builds/application.js (#{built.size} bytes)") if built.exist?
102
+ missing = entrypoints.keys.reject { |name| exist?("app/assets/builds/#{name}.js") }
103
+ return Check.new(:ok, "Build output", "app/assets/builds has #{entrypoints.keys.join(", ")}") if missing.empty?
103
104
 
104
- Check.new(:warn, "Build output", "app/assets/builds/application.js not built yet",
105
+ Check.new(:warn, "Build output", "not built yet: #{missing.join(", ")}",
105
106
  "Run `bin/rails rspack:build` (only needed without the dev server)")
106
107
  end
107
108
 
@@ -186,6 +187,10 @@ module RspackRails
186
187
  nil
187
188
  end
188
189
 
190
+ def entrypoints
191
+ @entrypoints ||= Entrypoints.discover(@root)
192
+ end
193
+
189
194
  def package_manager
190
195
  @package_manager ||= PackageManager.detect(@root)
191
196
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module RspackRails
6
+ # Mirrors the entry discovery in the generated rspack.config.mjs:
7
+ # app/javascript/application.{js,ts} plus every
8
+ # app/javascript/entrypoints/*.{js,ts}, named after the file.
9
+ module Entrypoints
10
+ EXTENSIONS = %w[js ts].freeze
11
+
12
+ module_function
13
+
14
+ # => { "application" => "app/javascript/application.js", "admin" => "app/javascript/entrypoints/admin.js" }
15
+ def discover(root)
16
+ root = Pathname(root)
17
+ entries = {}
18
+ main = EXTENSIONS.map { |ext| "app/javascript/application.#{ext}" }.find { |path| root.join(path).file? }
19
+ entries["application"] = main if main
20
+
21
+ Dir.glob("app/javascript/entrypoints/*.{#{EXTENSIONS.join(",")}}", base: root).sort.each do |path|
22
+ entries[File.basename(path, ".*")] = path
23
+ end
24
+ entries
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pathname"
5
+
6
+ module RspackRails
7
+ # Detects how an app bundles JavaScript today and works out what
8
+ # `rspack:migrate` has to change. Pure inspection: nothing here writes files.
9
+ class Migration
10
+ Pin = Struct.new(:name, :to, :version, keyword_init: true)
11
+
12
+ # importmap-only helpers with no npm equivalent.
13
+ IMPORTMAP_ONLY = %w[@hotwired/stimulus-loading].freeze
14
+
15
+ BUNDLER_PACKAGES = %w[esbuild webpack webpack-cli rollup @rollup/plugin-node-resolve
16
+ @rollup/plugin-commonjs @rollup/plugin-terser].freeze
17
+ BUNDLER_CONFIGS = %w[webpack.config.js rollup.config.js rollup.config.mjs].freeze
18
+
19
+ attr_reader :root
20
+
21
+ def initialize(root)
22
+ @root = Pathname(root)
23
+ end
24
+
25
+ # :rspack, :shakapacker, :importmap, :jsbundling or :none
26
+ def source
27
+ @source ||= if file?("rspack.config.mjs") then :rspack
28
+ elsif file?("config/shakapacker.yml") || file?("config/webpacker.yml") then :shakapacker
29
+ elsif file?("config/importmap.rb") then :importmap
30
+ elsif jsbundling? then :jsbundling
31
+ else
32
+ :none
33
+ end
34
+ end
35
+
36
+ def pins
37
+ @pins ||= parse_pins(read("config/importmap.rb").to_s)
38
+ end
39
+
40
+ # { "package" => "version or nil" } for every pin that comes from npm. The version is
41
+ # known only when importmap recorded it (vendored "# @1.2.3" pins or jspm URLs); gem-served
42
+ # pins such as turbo-rails install the latest npm release.
43
+ def npm_packages
44
+ pins.reject { |pin| local_pin?(pin) || IMPORTMAP_ONLY.include?(pin.name) }
45
+ .to_h { |pin| [package_name(pin.name), pin.version] }
46
+ end
47
+
48
+ # The argument list for `npm install` / `yarn add`, versions pinned when known.
49
+ def npm_install_args
50
+ npm_packages.map { |name, version| version ? "#{name}@#{version}" : name }
51
+ end
52
+
53
+ # Vendored copies of packages that now come from npm.
54
+ def vendored_files
55
+ pins.reject { |pin| local_pin?(pin) }.filter_map do |pin|
56
+ path = "vendor/javascript/#{pin.to || "#{pin.name}.js"}"
57
+ path if file?(path)
58
+ end
59
+ end
60
+
61
+ def bundler_packages
62
+ dev = package_json.fetch("devDependencies", {}).keys + package_json.fetch("dependencies", {}).keys
63
+ dev & BUNDLER_PACKAGES
64
+ end
65
+
66
+ def bundler_configs
67
+ BUNDLER_CONFIGS.select { |path| file?(path) }
68
+ end
69
+
70
+ # jsbundling's esbuild default builds every file in app/javascript; rspack
71
+ # only builds application.* and entrypoints/*.
72
+ def extra_root_entries
73
+ Dir.glob("app/javascript/*.{js,ts}", base: root).sort.grep_v(%r{/application\.[jt]s\z})
74
+ end
75
+
76
+ def parse_pins(source)
77
+ source.each_line.filter_map do |line|
78
+ match = line.match(/^\s*pin\s+["']([^"']+)["'](.*)$/)
79
+ next unless match
80
+
81
+ rest = match[2]
82
+ to = rest[/to:\s*["']([^"']+)["']/, 1]
83
+ version = rest[/#\s*@(\S+)/, 1] || to.to_s[%r{npm:(?:@[^/]+/)?[^@/]+@([^/]+)/}, 1]
84
+ Pin.new(name: match[1], to: to, version: version)
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def jsbundling?
91
+ read("Gemfile").to_s.match?(/^\s*gem\s+["']jsbundling-rails["']/) ||
92
+ package_json.dig("scripts", "build").to_s.match?(/\b(esbuild|webpack|rollup|bun build)\b/)
93
+ end
94
+
95
+ # A pin is app code when it, or the file it points to, lives in app/javascript.
96
+ def local_pin?(pin)
97
+ return true if pin.name == "application"
98
+
99
+ target = pin.to || "#{pin.name}.js"
100
+ return false if target.match?(%r{\Ahttps?://})
101
+
102
+ file?("app/javascript/#{target}")
103
+ end
104
+
105
+ # "@rails/actioncable/src" -> "@rails/actioncable", "lodash/debounce" -> "lodash"
106
+ def package_name(specifier)
107
+ parts = specifier.split("/")
108
+ specifier.start_with?("@") ? parts.first(2).join("/") : parts.first
109
+ end
110
+
111
+ def package_json
112
+ @package_json ||= file?("package.json") ? JSON.parse(read("package.json")) : {}
113
+ rescue JSON::ParserError
114
+ @package_json = {}
115
+ end
116
+
117
+ def file?(path)
118
+ root.join(path).file?
119
+ end
120
+
121
+ def read(path)
122
+ root.join(path).read if file?(path)
123
+ end
124
+ end
125
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RspackRails
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/rspack_rails.rb CHANGED
@@ -4,6 +4,8 @@ require_relative "rspack_rails/version"
4
4
  require_relative "rspack_rails/package_manager"
5
5
  require_relative "rspack_rails/configuration"
6
6
  require_relative "rspack_rails/dev_server"
7
+ require_relative "rspack_rails/entrypoints"
8
+ require_relative "rspack_rails/migration"
7
9
 
8
10
  # Rspack integration for Rails: rake tasks, installer and view helpers.
9
11
  module RspackRails
@@ -24,6 +24,20 @@ namespace :rspack do
24
24
  end
25
25
  end
26
26
 
27
+ desc "Migrate from importmap-rails or jsbundling-rails to rspack (FORCE=1 to skip the clean git check)"
28
+ task :migrate do
29
+ dirty = system("git", "rev-parse", "--is-inside-work-tree", out: File::NULL, err: File::NULL) &&
30
+ !`git status --porcelain`.strip.empty?
31
+ if dirty && ENV["FORCE"].to_s.empty?
32
+ raise RspackRails::Error,
33
+ "rspack:migrate removes and rewrites files. Commit or stash your changes first, or run with FORCE=1."
34
+ end
35
+
36
+ template = File.expand_path("../install/migrate.rb", __dir__)
37
+ system(RbConfig.ruby, "./bin/rails", "app:template", "LOCATION=#{template}") ||
38
+ raise(RspackRails::Error, "rspack:migrate failed")
39
+ end
40
+
27
41
  desc "Check the rspack setup of this app"
28
42
  task doctor: :environment do
29
43
  require "rspack_rails/doctor"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rspack_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - tungnt
@@ -45,6 +45,7 @@ files:
45
45
  - lib/install/application.js
46
46
  - lib/install/dev
47
47
  - lib/install/install.rb
48
+ - lib/install/migrate.rb
48
49
  - lib/install/package.json
49
50
  - lib/install/rspack.config.mjs
50
51
  - lib/install/stimulus.rb
@@ -57,7 +58,9 @@ files:
57
58
  - lib/rspack_rails/configuration.rb
58
59
  - lib/rspack_rails/dev_server.rb
59
60
  - lib/rspack_rails/doctor.rb
61
+ - lib/rspack_rails/entrypoints.rb
60
62
  - lib/rspack_rails/helper.rb
63
+ - lib/rspack_rails/migration.rb
61
64
  - lib/rspack_rails/node_version.rb
62
65
  - lib/rspack_rails/package_manager.rb
63
66
  - lib/rspack_rails/railtie.rb