@@ -72,7 +66,7 @@ This project strives to live up to the vision outlined in [The Rails Doctrine](h
- [Build a Twitter Clone in 10 Minutes](https://youtu.be/F5hA79vKE_E) (video)
- [BeastMode](https://beastmode.leastbad.com/) - faceted search, with filtering, sorting and pagination
- [StimulusReflex Patterns](https://www.stimulusreflexpatterns.com/patterns/) - single-file SR apps hosted on Glitch
-- [BoxDrop](https://dropbox-clone-rails.herokuapp.com/) - a Dropbox-inspired [concept demo](https://github.com/marcoroth/boxdrop/)
+- [Boxdrop](https://www.boxdrop.io) - a Dropbox-inspired [concept demo](https://github.com/marcoroth/boxdrop/)
## 👩👩👧 Discord Community
@@ -84,7 +78,7 @@ Stop by #newcomers and introduce yourselves!
## 💙 Support
-Your best bet is to ask for help on Discord before filing an issue on Github. We are happy to help, and we ask people who need help to come with all relevant code to look at. A git repo is preferred, but Gists are fine, too. If you need an MVCE template, try [this](https://github.com/leastbad/stimulus_reflex_harness).
+Your best bet is to ask for help on Discord before filing an issue on GitHub. We are happy to help, and we ask people who need help to come with all relevant code to look at. A git repo is preferred, but Gists are fine, too. If you need a template for reproducing your issue, try [this](https://github.com/leastbad/stimulus_reflex_harness).
Please note that we are not actively providing support on Stack Overflow. If you post there, we likely won't see it.
diff --git a/lib/generators/stimulus_reflex/initializer_generator.rb b/lib/generators/stimulus_reflex/initializer_generator.rb
deleted file mode 100644
index fffe0ee0..00000000
--- a/lib/generators/stimulus_reflex/initializer_generator.rb
+++ /dev/null
@@ -1,14 +0,0 @@
-# frozen_string_literal: true
-
-require "rails/generators"
-
-module StimulusReflex
- class InitializerGenerator < Rails::Generators::Base
- desc "Creates a StimulusReflex initializer in config/initializers"
- source_root File.expand_path("templates", __dir__)
-
- def copy_initializer_file
- copy_file "config/initializers/stimulus_reflex.rb"
- end
- end
-end
diff --git a/lib/generators/stimulus_reflex/stimulus_reflex_generator.rb b/lib/generators/stimulus_reflex/stimulus_reflex_generator.rb
index a510858e..5971b459 100644
--- a/lib/generators/stimulus_reflex/stimulus_reflex_generator.rb
+++ b/lib/generators/stimulus_reflex/stimulus_reflex_generator.rb
@@ -1,27 +1,92 @@
# frozen_string_literal: true
require "rails/generators"
+require "stimulus_reflex/version"
class StimulusReflexGenerator < Rails::Generators::NamedBase
source_root File.expand_path("templates", __dir__)
argument :name, type: :string, required: true, banner: "NAME"
argument :actions, type: :array, default: [], banner: "action action"
- class_options skip_stimulus: false, skip_app_reflex: false, skip_reflex: false, skip_app_controller: false
+ class_options skip_stimulus: false, skip_reflex: false
def execute
actions.map!(&:underscore)
- copy_application_files if behavior == :invoke
+ cached_entrypoint = Rails.root.join("tmp/stimulus_reflex_installer/entrypoint")
+ if cached_entrypoint.exist?
+ entrypoint = File.read(cached_entrypoint)
+ else
+ entrypoint = [
+ Rails.root.join("app/javascript"),
+ Rails.root.join("app/frontend")
+ ].find(&:exist?) || "app/javascript"
+ puts "Where do JavaScript files live in your app? Our best guess is: \e[1#{entrypoint}\e[22m 🤔"
+ puts "Press enter to accept this, or type a different path."
+ print "> "
+ input = Rails.env.test? ? "tmp/app/javascript" : $stdin.gets.chomp
+ entrypoint = input unless input.blank?
+ end
- template "app/reflexes/%file_name%_reflex.rb" unless options[:skip_reflex]
- template "app/javascript/controllers/%file_name%_controller.js" unless options[:skip_stimulus]
+ if !options[:skip_stimulus] && entrypoint.blank?
+ puts "❌ You must specify a valid JavaScript entrypoint."
+ exit
+ end
+
+ reflex_entrypoint = Rails.env.test? ? "tmp/app/reflexes" : "app/reflexes"
+ reflex_src = fetch("/app/reflexes/%file_name%_reflex.rb.tt")
+ reflex_path = Rails.root.join(reflex_entrypoint, "#{file_name}_reflex.rb")
+ stimulus_controller_src = fetch("/app/javascript/controllers/%file_name%_controller.js.tt")
+ stimulus_controller_path = Rails.root.join(entrypoint, "controllers/#{file_name}_controller.js")
+
+ template(reflex_src, reflex_path) unless options[:skip_reflex]
+ template(stimulus_controller_src, stimulus_controller_path) unless options[:skip_stimulus]
+
+ if file_name == "example"
+ controller_src = fetch("/app/controllers/examples_controller.rb.tt")
+ controller_path = Rails.root.join("app/controllers/examples_controller.rb")
+ template(controller_src, controller_path)
+
+ view_src = fetch("/app/views/examples/show.html.erb.tt")
+ view_path = Rails.root.join("app/views/examples/show.html.erb")
+ template(view_src, view_path)
+
+ example_path = Rails.root.join("app/views/examples")
+ FileUtils.remove_dir(example_path) if behavior == :revoke && example_path.exist? && Dir.empty?(example_path)
+
+ route "resource :example, constraints: -> { Rails.env.development? }"
+
+ importmap_path = Rails.root.join("config/importmap.rb")
+
+ if importmap_path.exist?
+ importmap = importmap_path.read
+
+ if behavior == :revoke
+ if importmap.include?("pin \"fireworks-js\"")
+ importmap_path.write importmap_path.readlines.reject { |line| line.include?("pin \"fireworks-js\"") }.join
+ say "✅ unpin fireworks-js"
+ else
+ say "⏩ fireworks-js not pinned. Skipping."
+ end
+ end
+
+ if behavior == :invoke
+ if importmap.include?("pin \"fireworks-js\"")
+ say "⏩ fireworks-js already pinnned. Skipping."
+ else
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "fireworks-js", to: "https://ga.jspm.io/npm:fireworks-js@2.10.0/dist/index.es.js"
+ RUBY
+ say "✅ pin fireworks-js"
+ end
+ end
+ end
+ end
end
private
- def copy_application_files
- template "app/reflexes/application_reflex.rb" unless options[:skip_app_reflex]
- template "app/javascript/controllers/application_controller.js" unless options[:skip_app_controller]
+ def fetch(file)
+ source_paths.first + file
end
end
diff --git a/lib/generators/stimulus_reflex/templates/app/controllers/examples_controller.rb.tt b/lib/generators/stimulus_reflex/templates/app/controllers/examples_controller.rb.tt
new file mode 100644
index 00000000..d67445f2
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/controllers/examples_controller.rb.tt
@@ -0,0 +1,9 @@
+class ExamplesController < ApplicationController
+ layout false
+
+ def show
+ respond_to do |format|
+ format.html
+ end
+ end
+end
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/consumer.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/consumer.js.tt
new file mode 100644
index 00000000..4568047d
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/consumer.js.tt
@@ -0,0 +1,6 @@
+// Action Cable provides the framework to deal with WebSockets in Rails.
+// You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
+
+import { createConsumer } from '@rails/actioncable'
+
+export default createConsumer()
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.esbuild.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.esbuild.tt
new file mode 100644
index 00000000..ac7269bf
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.esbuild.tt
@@ -0,0 +1,4 @@
+// Load all the channels within this directory and all subdirectories.
+// Channel files must be named *_channel.js.
+
+import './**/*_channel.js'
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.importmap.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.importmap.tt
new file mode 100644
index 00000000..7f993d94
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.importmap.tt
@@ -0,0 +1,2 @@
+// app/channels/index.js
+// Importmaps don't require anything here
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.shakapacker.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.shakapacker.tt
new file mode 100644
index 00000000..0cfcf749
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.shakapacker.tt
@@ -0,0 +1,5 @@
+// Load all the channels within this directory and all subdirectories.
+// Channel files must be named *_channel.js.
+
+const channels = require.context('.', true, /_channel\.js$/)
+channels.keys().forEach(channels)
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.vite.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.vite.tt
new file mode 100644
index 00000000..cf11aaa4
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.vite.tt
@@ -0,0 +1 @@
+const channels = import.meta.globEager('./**/*_channel.js')
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.webpacker.tt b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.webpacker.tt
new file mode 100644
index 00000000..0cfcf749
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/channels/index.js.webpacker.tt
@@ -0,0 +1,5 @@
+// Load all the channels within this directory and all subdirectories.
+// Channel files must be named *_channel.js.
+
+const channels = require.context('.', true, /_channel\.js$/)
+channels.keys().forEach(channels)
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/config/cable_ready.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/config/cable_ready.js.tt
new file mode 100644
index 00000000..02adc819
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/config/cable_ready.js.tt
@@ -0,0 +1,4 @@
+import consumer from '../channels/consumer'
+import CableReady from 'cable_ready'
+
+CableReady.initialize({ consumer })
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/config/index.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/config/index.js.tt
new file mode 100644
index 00000000..128765b9
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/config/index.js.tt
@@ -0,0 +1,2 @@
+import './cable_ready'
+import './stimulus_reflex'
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/config/mrujs.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/config/mrujs.js.tt
new file mode 100644
index 00000000..dda4691b
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/config/mrujs.js.tt
@@ -0,0 +1,9 @@
+import CableReady from "cable_ready"
+import mrujs from "mrujs"
+import { CableCar } from "mrujs/plugins"
+
+mrujs.start({
+ plugins: [
+ new CableCar(CableReady)
+ ]
+})
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/config/stimulus_reflex.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/config/stimulus_reflex.js.tt
new file mode 100644
index 00000000..c978eac0
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/config/stimulus_reflex.js.tt
@@ -0,0 +1,5 @@
+import { application } from "../controllers/application"
+import controller from "../controllers/application_controller"
+import StimulusReflex from "stimulus_reflex"
+
+StimulusReflex.initialize(application, { controller, isolate: true })
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/%file_name%_controller.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/%file_name%_controller.js.tt
index de367d48..8c46f7e7 100644
--- a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/%file_name%_controller.js.tt
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/%file_name%_controller.js.tt
@@ -1,131 +1,141 @@
import ApplicationController from './application_controller'
-/* This is the custom StimulusReflex controller for the <%= class_name %> Reflex.
- * Learn more at: https://docs.stimulusreflex.com
- */
+<%- if class_name == "Example" -%>
+//
+// This is the Stimulus controller for the Example Reflex.
+//
+// It corresponds to the server-side Reflex class located at /app/reflexes/example.rb
+//
+// Learn more at: https://docs.stimulusreflex.com/guide/reflexes
+//
+
+<%- end -%>
export default class extends ApplicationController {
- /*
- * Regular Stimulus lifecycle methods
- * Learn more at: https://stimulusjs.org/reference/lifecycle-callbacks
- *
- * If you intend to use this controller as a regular stimulus controller as well,
- * make sure any Stimulus lifecycle methods overridden in ApplicationController call super.
- *
- * Important:
- * By default, StimulusReflex overrides the -connect- method so make sure you
- * call super if you intend to do anything else when this controller connects.
- */
-
connect () {
+<%- if class_name == "Example" -%>
+ //
+ // StimulusReflex overrides the Stimulus `connect` method. Make sure to call
+ // `super.connect()` so that any code in your superclass `connect` is run.
+ //
+<%- end -%>
super.connect()
- // add your code here, if applicable
}
+<%- if class_name == "Example" -%>
+
+ // With StimulusReflex active in your project, it will continuously scan your DOM for
+ // `data-reflex` attributes on your elements, even if they are dynamically created.
+ //
+ // Dance!
+ //
+ // We call this a "declared" Reflex, because it doesn't require any JS to run.
+ //
+ // When your user clicks this link, a Reflex is launched and it calls the `dance` method
+ // on your Example Reflex class. You don't have to do anything else!
+ //
+ // This Stimulus controller doesn't even need to exist for StimulusReflex to work its magic.
+ // https://docs.stimulusreflex.com/guide/reflexes#declaring-a-reflex-in-html-with-data-attributes
+ //
+ // However...
+ //
+ // If we do create an `example` Stimulus controller that extends `ApplicationController`,
+ // we can unlock several additional capabilities, including creating Reflexes with code.
+ //
+ // Dance!
+ //
+ // StimulusReflex gives our controller a new method, `stimulate`:
+ //
+ // dance() {
+ // this.stimulate('Example#dance')
+ // }
+ //
+ // The `stimulate` method is very flexible, and it gives you the opportunity to pass
+ // parameter options that will be passed to the `dance` method on the server.
+ // https://docs.stimulusreflex.com/guide/reflexes#calling-a-reflex-in-a-stimulus-controller
+ //
+ // Reflex lifecycle methods
+ //
+ // For every method defined in your Reflex class, a matching set of optional
+ // lifecycle callback methods become available in this javascript controller.
+ // https://docs.stimulusreflex.com/guide/lifecycle#client-side-reflex-callbacks
+ //
+ // Dance!
+ //
+ // StimulusReflex will check for the presence of several methods:
+ //
+ // afterReflex(element, reflex, noop, id) {
+ // // fires after every Example Reflex action
+ // }
+ //
+ // afterDance(element, reflex, noop, id) {
+ // // fires after Example Reflexes calling the dance action
+ // }
+ //
+ // Arguments:
+ //
+ // element - the element that triggered the reflex
+ // may be different than the Stimulus controller's this.element
+ //
+ // reflex - the name of the reflex e.g. "Example#dance"
+ //
+ // error/noop - the error message (for reflexError), otherwise null
+ //
+ // id - a UUID4 or developer-provided unique identifier for each Reflex
+ //
+ // Access to the client-side Reflex objects created by this controller
+ //
+ // Every Reflex you create is represented by an object in the global Reflexes collection.
+ // You can access the Example Reflexes created by this controller via the `reflexes` proxy.
+ //
+ // this.reflexes.last
+ // this.reflexes.all
+ // this.reflexes.all[id]
+ // this.reflexes.error
+ //
+ // The proxy allows you to access the most recent Reflex, an array of all Reflexes, a specific
+ // Reflex specified by its `id` and an array of all Reflexes in a given lifecycle stage.
+ //
+ // If you explore the Reflex object, you'll see all relevant details,
+ // including the `data` that is being delivered to the server.
+ //
+ // Pretty cool, right?
+ //
+<%- end -%>
+<%- actions.each do |action| -%>
- /* Reflex specific lifecycle methods.
- *
- * For every method defined in your Reflex class, a matching set of lifecycle methods become available
- * in this javascript controller. These are optional, so feel free to delete these stubs if you don't
- * need them.
- *
- * Important:
- * Make sure to add data-controller="<%= class_name.underscore.dasherize %>" to your markup alongside
- * data-reflex="<%= class_name %>#dance" for the lifecycle methods to fire properly.
- *
- * Example:
- *
- * Dance!
- *
- * Arguments:
- *
- * element - the element that triggered the reflex
- * may be different than the Stimulus controller's this.element
- *
- * reflex - the name of the reflex e.g. "<%= class_name %>#dance"
- *
- * error/noop - the error message (for reflexError), otherwise null
- *
- * id - a UUID4 or developer-provided unique identifier for each Reflex
- */
-
-<% if actions.empty? -%>
- // Assuming you create a "<%= class_name %>#dance" action in your Reflex class
- // you'll be able to use the following lifecycle methods:
-
- // beforeDance(element, reflex, noop, id) {
- // element.innerText = 'Putting dance shoes on...'
- // }
-
- // danceQueued(element, reflex, noop, id) {
- // element.innerText = 'Waiting in the wings'
- // }
-
- // danceDelivered(element, reflex, noop, id) {
- // element.innerText = 'My big moment'
- // }
-
- // danceSuccess(element, reflex, noop, id) {
- // element.innerText = 'Danced like no one was watching! Was someone watching?'
- // }
-
- // danceError(element, reflex, error, id) {
- // console.error('danceError', error);
- // element.innerText = 'Could not dance!'
- // }
-
- // danceForbidden(element, reflex, noop, id) {
- // console.warn('danceForbidden');
- // element.innerText = 'Dancing is forbidden in Bomont.'
- // }
-
- // danceHalted(element, reflex, noop, id) {
- // console.warn('danceHalted');
- // element.innerText = 'Nobody puts Baby in a corner.'
- // }
-
- // afterDance(element, reflex, noop, id) {
- // element.innerText = 'Whatever that was, it is over now.'
- // }
-
- // finalizeDance(element, reflex, noop, id) {
- // element.innerText = 'Now, the cleanup can begin!'
- // }
-<% end -%>
-<% actions.each do |action| -%>
// <%= "before_#{action}".camelize(:lower) %>(element, reflex, noop, id) {
// console.log("before <%= action %>", element, reflex, id)
// }
// <%= "#{action}_queued".camelize(:lower) %>(element, reflex, noop, id) {
- // console.log("<%= action %> queued", element, reflex, id)
+ // console.log("<%= action %> enqueued for delivery upon connection", element, reflex, id)
// }
// <%= "#{action}_delivered".camelize(:lower) %>(element, reflex, noop, id) {
- // console.log("<%= action %> delivered", element, reflex, id)
+ // console.log("<%= action %> delivered to the server", element, reflex, id)
// }
// <%= "#{action}_success".camelize(:lower) %>(element, reflex, noop, id) {
- // console.log("<%= action %> success", element, reflex, id)
+ // console.log("<%= action %> successfully executed", element, reflex, id)
// }
// <%= "#{action}_error".camelize(:lower) %>(element, reflex, error, id) {
- // console.error("<%= action %> error", element, reflex, error, id)
+ // console.error("<%= action %> server-side error", element, reflex, error, id)
// }
// <%= "#{action}_halted".camelize(:lower) %>(element, reflex, noop, id) {
- // console.warn("<%= action %> halted", element, reflex, id)
+ // console.warn("<%= action %> halted before execution", element, reflex, id)
// }
// <%= "#{action}_forbidden".camelize(:lower) %>(element, reflex, noop, id) {
- // console.warn("<%= action %> forbidden", element, reflex, id)
+ // console.warn("<%= action %> forbidden from executing", element, reflex, id)
// }
// <%= "after_#{action}".camelize(:lower) %>(element, reflex, noop, id) {
- // console.log("after <%= action %>", element, reflex, id)
+ // console.log("<%= action %> has been executed by the server", element, reflex, id)
// }
// <%= "finalize_#{action}".camelize(:lower) %>(element, reflex, noop, id) {
- // console.log("finalize <%= action %>", element, reflex, id)
+ // console.log("<%= action %> changes have been applied", element, reflex, id)
// }
-<%= "\n" unless action == actions.last -%>
-<% end -%>
+<%- end -%>
}
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application.js.tt
new file mode 100644
index 00000000..509ebb73
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application.js.tt
@@ -0,0 +1,11 @@
+import { Application } from "@hotwired/stimulus"
+import consumer from "../channels/consumer"
+
+const application = Application.start()
+
+// Configure Stimulus development experience
+application.debug = false
+application.consumer = consumer
+window.Stimulus = application
+
+export { application }
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application_controller.js.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application_controller.js.tt
index ea062dc0..bcf40cfd 100644
--- a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application_controller.js.tt
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/application_controller.js.tt
@@ -1,38 +1,39 @@
import { Controller } from '@hotwired/stimulus'
import StimulusReflex from 'stimulus_reflex'
-/* This is your ApplicationController.
- * All StimulusReflex controllers should inherit from this class.
- *
- * Example:
- *
- * import ApplicationController from './application_controller'
- *
- * export default class extends ApplicationController { ... }
- *
- * Learn more at: https://docs.stimulusreflex.com
- */
+// This is the Stimulus ApplicationController.
+// All StimulusReflex controllers should inherit from this class.
+//
+// Example:
+//
+// import ApplicationController from './application_controller'
+//
+// export default class extends ApplicationController { ... }
+//
+// Learn more at: https://docs.stimulusreflex.com
+//
+
export default class extends Controller {
connect () {
StimulusReflex.register(this)
}
- /* Application-wide lifecycle methods
- *
- * Use these methods to handle lifecycle concerns for the entire application.
- * Using the lifecycle is optional, so feel free to delete these stubs if you don't need them.
- *
- * Arguments:
- *
- * element - the element that triggered the reflex
- * may be different than the Stimulus controller's this.element
- *
- * reflex - the name of the reflex e.g. "Example#demo"
- *
- * error/noop - the error message (for reflexError), otherwise null
- *
- * id - a UUID4 or developer-provided unique identifier for each Reflex
- */
+ // Application-wide lifecycle methods
+ //
+ // Use these methods to handle lifecycle callbacks for all controllers.
+ // Using lifecycle methods is optional, so feel free to delete these if you don't need them.
+ //
+ // Arguments:
+ //
+ // element - the element that triggered the reflex
+ // may be different than the Stimulus controller's this.element
+ //
+ // reflex - the name of the reflex e.g. "Example#demo"
+ //
+ // error/noop - the error message (for reflexError), otherwise null
+ //
+ // id - a UUID4 or developer-provided unique identifier for each Reflex
+ //
beforeReflex (element, reflex, noop, id) {
// document.body.classList.add('wait')
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.esbuild.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.esbuild.tt
new file mode 100644
index 00000000..22998ef4
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.esbuild.tt
@@ -0,0 +1,7 @@
+import { application } from "./application"
+
+import controllers from "./**/*_controller.js"
+
+controllers.forEach((controller) => {
+ application.register(controller.name, controller.module.default)
+})
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.importmap.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.importmap.tt
new file mode 100644
index 00000000..9760d2d4
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.importmap.tt
@@ -0,0 +1,5 @@
+import { application } from "./application"
+
+// Eager load all controllers defined in the import map under controllers/**/*_controller
+import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
+eagerLoadControllersFrom("controllers", application)
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.shakapacker.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.shakapacker.tt
new file mode 100644
index 00000000..947f340b
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.shakapacker.tt
@@ -0,0 +1,5 @@
+import { application } from "./application"
+import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
+
+const controllers = definitionsFromContext(require.context("controllers", true, /_controller\.js$/))
+application.load(controllers)
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.vite.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.vite.tt
new file mode 100644
index 00000000..c6c06c23
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.vite.tt
@@ -0,0 +1,5 @@
+import { application } from "./application"
+import { registerControllers } from "stimulus-vite-helpers"
+
+const controllers = import.meta.globEager("./**/*_controller.js");
+registerControllers(application, controllers)
diff --git a/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.webpacker.tt b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.webpacker.tt
new file mode 100644
index 00000000..947f340b
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/javascript/controllers/index.js.webpacker.tt
@@ -0,0 +1,5 @@
+import { application } from "./application"
+import { definitionsFromContext } from "@hotwired/stimulus-webpack-helpers"
+
+const controllers = definitionsFromContext(require.context("controllers", true, /_controller\.js$/))
+application.load(controllers)
diff --git a/lib/generators/stimulus_reflex/templates/app/reflexes/%file_name%_reflex.rb.tt b/lib/generators/stimulus_reflex/templates/app/reflexes/%file_name%_reflex.rb.tt
index 0483c087..28ebed44 100644
--- a/lib/generators/stimulus_reflex/templates/app/reflexes/%file_name%_reflex.rb.tt
+++ b/lib/generators/stimulus_reflex/templates/app/reflexes/%file_name%_reflex.rb.tt
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class <%= class_name %>Reflex < ApplicationReflex
+<%- if class_name == "Example" -%>
# Add Reflex methods in this file.
#
# All Reflex instances include CableReady::Broadcaster and expose the following properties:
@@ -16,26 +17,56 @@ class <%= class_name %>Reflex < ApplicationReflex
# - signed - use a signed Global ID to map dataset attribute to a model eg. element.signed[:foo]
# - unsigned - use an unsigned Global ID to map dataset attribute to a model eg. element.unsigned[:foo]
# - cable_ready - a special cable_ready that can broadcast to the current visitor (no brackets needed)
- # - id - a UUIDv4 that uniquely identies each Reflex
+ # - id - a UUIDv4 that uniquely identifies each Reflex
# - tab_id - a UUIDv4 that uniquely identifies the browser tab
#
# Example:
#
# before_reflex do
# # throw :abort # this will prevent the Reflex from continuing
- # # learn more about callbacks at https://docs.stimulusreflex.com/rtfm/lifecycle
+ # # learn more about callbacks at https://docs.stimulusreflex.com/guide/lifecycle
# end
#
# def example(argument=true)
- # # Your logic here...
+ # # Your logic here! Update models, launch jobs, poll Redis...
+ #
+ # # By default, Reflexes call the controller action for the current page and render the view.
# # Any declared instance variables will be made available to the Rails controller and view.
+ #
+ # # You can also use the `morph` method to mark a Reflex as Selector or Nothing.
+ # # https://docs.stimulusreflex.com/guide/morph-modes#introducing-morphs
# end
#
- # Learn more at: https://docs.stimulusreflex.com/rtfm/reflex-classes
+ # Learn more at: https://docs.stimulusreflex.com/guide/reflex-classes
+
+<%- end -%>
+<%- if class_name == "Example" -%>
+ def increment
+ count = session[:count] || 0
+ session[:count] = count + 1
+
+ morph "#time", Time.now
+ morph "#count", session[:count]
+
+ cable_ready.set_value(selector: "progress", value: session[:count])
-<% actions.each do |action| -%>
+ if session[:count] == 5
+ cable_ready.text_content(selector: "#reload", text: "Try reloading your browser window. The count is persisted in the session!")
+ end
+
+ if session[:count] >= 10
+ cable_ready.dispatch_event(name: "fireworks")
+ end
+ end
+
+ def reset
+ session[:count] = 0
+ end
+<%- else -%>
+ <%- actions.each do |action| -%>
def <%= action %>
end
-<%= "\n" unless action == actions.last -%>
-<% end -%>
+ <%= "\n" unless action == actions.last -%>
+ <%- end -%>
+<%- end -%>
end
diff --git a/lib/generators/stimulus_reflex/templates/app/reflexes/application_reflex.rb.tt b/lib/generators/stimulus_reflex/templates/app/reflexes/application_reflex.rb.tt
index 387bee17..59eda0e5 100644
--- a/lib/generators/stimulus_reflex/templates/app/reflexes/application_reflex.rb.tt
+++ b/lib/generators/stimulus_reflex/templates/app/reflexes/application_reflex.rb.tt
@@ -3,11 +3,19 @@
class ApplicationReflex < StimulusReflex::Reflex
# Put application-wide Reflex behavior and callbacks in this file.
#
- # Learn more at: https://docs.stimulusreflex.com/rtfm/reflex-classes
+ # Learn more at: https://docs.stimulusreflex.com/guide/reflex-classes
#
# If your ActionCable connection is: `identified_by :current_user`
# delegate :current_user, to: :connection
#
+ # current_user delegation alloqs you to use the Current pattern, too:
+ # before_reflex do
+ # Current.user = current_user
+ # end
+ #
+ # To access view helpers inside Reflexes:
+ # delegate :helpers, to: :ApplicationController
+ #
# If you need to localize your Reflexes, you can set the I18n locale here:
#
# before_reflex do
@@ -15,5 +23,5 @@ class ApplicationReflex < StimulusReflex::Reflex
# end
#
# For code examples, considerations and caveats, see:
- # https://docs.stimulusreflex.com/rtfm/patterns#internationalization
+ # https://docs.stimulusreflex.com/guide/patterns#internationalization
end
diff --git a/lib/generators/stimulus_reflex/templates/app/views/examples/show.html.erb.tt b/lib/generators/stimulus_reflex/templates/app/views/examples/show.html.erb.tt
new file mode 100644
index 00000000..ead58ff1
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/app/views/examples/show.html.erb.tt
@@ -0,0 +1,207 @@
+
+
+
+ StimulusReflex Demo
+
+
+ <%%= csrf_meta_tags %>
+ <%%= csp_meta_tag %>
+
+ <%% unless Rails.root.join("config/importmap.rb").exist? %>
+
+ <%% end %>
+
+ <%% if respond_to?(:vite_javascript_tag) %>
+ <%%= vite_client_tag %>
+ <%%= vite_javascript_tag "application", defer: true %>
+ <%% elsif respond_to?(:javascript_pack_tag) %>
+ <%%= javascript_pack_tag "application", defer: true %>
+ <%% elsif respond_to?(:javascript_importmap_tags) %>
+ <%%= javascript_importmap_tags %>
+ <%% elsif respond_to?(:javascript_include_tag) %>
+ <%%= javascript_include_tag "application", defer: true %>
+ <%% end %>
+
+
+
+
+
+
+
+
+
+
StimulusReflex
+
+
Actual demonstrations will show up in this space, soon. In the meantime, verify that your installation was successful:
CableReady lets you control one or many clients from the server in real-time.
+
+
Everything in CableReady revolves around its 38+operations, which are commands that can update content, raise events, write cookies and even play audio. A group of one or more operations is called a broadcast. Broadcasts follow a simple JSON format.
+
+
We're going to go through the main ways developers use CableReady with some live demonstrations and code samples. We recommend that you open the controller class and ERB template for this page to follow along.
+
+
+
Subscribe to a channel to receive broadcasts
+
+ WebSockets is the primary way most Rails developers use CableReady, via the cable_ready method.
+
+
Use the cable_ready_stream_from helper to create a secure Action Cable subscription:
Every user looking at a page subscribed to the :example_page channel will receive the same broadcasts.
+
+
You can call cable_ready pretty much anywhere in your application. Try it in your rails console now:
+
+ include CableReady::Broadcaster cable_ready[:example_page].text_content("#cable_ready_stream_from_output", text: "Hello from the console!").broadcast
+
+
Any message you send will appear in the #cable_ready_stream_from_output DIV below — even if you open multiple tabs.
These examples barely scrape the surface of what's possible. Be sure to check out the Stream Identifiers chapter.
+
+
+
+
Updatable: magically update the DOM when server-side data changes
+
+
The updates_for helper allow you to designate sections of your page that will update automatically with new content when an Active Record model changes. 🤯
+
+ It's difficult to demonstrate this feature without creating a temporary model and a migration; a road to hell, paved with good intentions. However, you likely have these models (or similar) in your application. Uncomment, tweak if necessary and follow along!
+
+
First, call enable_updates in your model. You can use it on associations, too.
+
+ class User < ApplicationRecord
+ enable_updates
+ has_many :posts, enable_updates: true
+ end
+
+ class Post < ApplicationRecord
+ belongs_to :user
+ end
+
+
+
By default, updates will be broadcast when any CRUD action is performed on the model. You can customize this behavior by passing options such as on: [:create, :update] or if: -> { id.even? }.
+
+
Next, use the updates_for helper to create one or more containers that will receive content updates.
+
+ <%= cable_ready_updates_for current_user do %>
+ <%= current_user.name %>
+ <% end %>
+
+
+
+
+
Update the current user in Rails console, and your page instantly reflects the new name. 🪄
+
+
Specify the class constant to get updates when records are created or deleted:
+
+ <%= cable_ready_updates_for User do %>
+ <ul>
+ <% @users.each do |user| %>
+ <li><%= user.name %></li>
+ <% end %>
+ </ul>
+ <% end %>
+
+
+
+
+
Update when new posts are created by the current user:
+
+ <%= cable_ready_updates_for current_user, :posts do %>
+ <ul>
+ <% @posts.each do |post| %>
+ <li><%= post.title %></li>
+ <% end %>
+ </ul>
+ <% end %>
+
+
+
+
+
One major advantage of the Updatable approach is that each visitor sees personalized content. This is difficult with a WebSockets broadcast, where every subscriber receives the same data.
+
+
Instead, Updatable notifies all subscribers that an update is available, prompting each client to make a fetch request and refresh sections of the page.
+
+
There's more to Updatable than what's covered here... but, not much more. It really is that simple.
+
+
+
+
If you're finished with this example page and resource controller, you can destroy them:
+
+ rails destroy stimulus_reflex example
+
+
+
As always, please drop by the StimulusReflex Discord server if you have any questions or need support of any kind. We're incredibly proud of the community that has formed around these libraries, and we discuss everything from JavaScript/Ruby/CSS to View Component/Phlex to databases and CRDTs. We'd love to hear what you're building with StimulusReflex and CableReady.
+
+
You can find the documentation for StimulusReflex here and CableReady here.
+
+
+
+
diff --git a/lib/generators/stimulus_reflex/templates/config/initializers/cable_ready.rb b/lib/generators/stimulus_reflex/templates/config/initializers/cable_ready.rb
new file mode 100644
index 00000000..3618f8f3
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/config/initializers/cable_ready.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+CableReady.configure do |config|
+ # Enable/disable exiting / warning when the sanity checks fail options:
+ # `:exit` or `:warn` or `:ignore`
+ #
+ # config.on_failed_sanity_checks = :exit
+
+ # Enable/disable assets compilation
+ # `true` or `false`
+ #
+ # config.precompile_assets = true
+
+ # Define your own custom operations
+ # https://cableready.stimulusreflex.com/customization#custom-operations
+ #
+ # config.add_operation_name :jazz_hands
+
+ # Change the default Active Job queue used for broadcast_later and broadcast_later_to
+ #
+ # config.broadcast_job_queue = :default
+end
diff --git a/lib/generators/stimulus_reflex/templates/config/initializers/stimulus_reflex.rb b/lib/generators/stimulus_reflex/templates/config/initializers/stimulus_reflex.rb
index 48e96ec5..78ab9534 100644
--- a/lib/generators/stimulus_reflex/templates/config/initializers/stimulus_reflex.rb
+++ b/lib/generators/stimulus_reflex/templates/config/initializers/stimulus_reflex.rb
@@ -6,7 +6,7 @@
# ActionCable.server.config.logger = Logger.new(nil)
StimulusReflex.configure do |config|
- # Enable/disable exiting / warning when the sanity checks fail options:
+ # Enable/disable exiting / warning when the sanity checks fail:
# `:exit` or `:warn` or `:ignore`
#
# config.on_failed_sanity_checks = :exit
diff --git a/lib/generators/stimulus_reflex/templates/esbuild.config.mjs.tt b/lib/generators/stimulus_reflex/templates/esbuild.config.mjs.tt
new file mode 100644
index 00000000..34ef9e99
--- /dev/null
+++ b/lib/generators/stimulus_reflex/templates/esbuild.config.mjs.tt
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+
+// Esbuild is configured with 3 modes:
+//
+// `yarn build` - Build JavaScript and exit
+// `yarn build --watch` - Rebuild JavaScript on change
+// `yarn build --reload` - Reloads page when views, JavaScript, or stylesheets change
+//
+// Minify is enabled when "RAILS_ENV=production"
+// Sourcemaps are enabled in non-production environments
+
+import * as esbuild from "esbuild"
+import path from "path"
+import rails from "esbuild-rails"
+import chokidar from "chokidar"
+import http from "http"
+import { setTimeout } from "timers/promises"
+
+const clients = []
+
+const entryPoints = [
+ "application.js"
+]
+
+const watchDirectories = [
+ "./app/javascript/**/*.js",
+ "./app/views/**/*.html.erb",
+ "./app/assets/builds/**/*.css", // Wait for cssbundling changes
+]
+
+const config = {
+ absWorkingDir: path.join(process.cwd(), "app/javascript"),
+ bundle: true,
+ entryPoints: entryPoints,
+ minify: process.env.RAILS_ENV == "production",
+ outdir: path.join(process.cwd(), "app/assets/builds"),
+ plugins: [rails()],
+ sourcemap: process.env.RAILS_ENV != "production"
+}
+
+async function buildAndReload() {
+ // Foreman & Overmind assign a separate PORT for each process
+ const port = parseInt(process.env.PORT)
+ const context = await esbuild.context({
+ ...config,
+ banner: {
+ js: ` (() => new EventSource("http://localhost:${port}").onmessage = () => location.reload())();`,
+ }
+ })
+
+ // Reload uses an HTTP server as an even stream to reload the browser
+ http.createServer((req, res) => {
+ return clients.push(
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream",
+ "Cache-Control": "no-cache",
+ "Access-Control-Allow-Origin": "*",
+ Connection: "keep-alive",
+ })
+ )
+ }).listen(port)
+
+ await context.rebuild()
+ console.log("[reload] initial build succeeded")
+
+ let ready = false
+ chokidar.watch(watchDirectories).on("ready", () => {
+ console.log("[reload] ready")
+ ready = true
+ }).on("all", async (event, path) => {
+ if (ready === false) return
+
+ if (path.includes("javascript")) {
+ try {
+ await setTimeout(20)
+ await context.rebuild()
+ console.log("[reload] build succeeded")
+ } catch (error) {
+ console.error("[reload] build failed", error)
+ }
+ }
+ clients.forEach((res) => res.write("data: update\n\n"))
+ clients.length = 0
+ })
+}
+
+if (process.argv.includes("--reload")) {
+ buildAndReload()
+} else if (process.argv.includes("--watch")) {
+ let context = await esbuild.context({...config, logLevel: 'info'})
+ context.watch()
+} else {
+ esbuild.build(config)
+}
diff --git a/lib/install/action_cable.rb b/lib/install/action_cable.rb
new file mode 100644
index 00000000..349319e1
--- /dev/null
+++ b/lib/install/action_cable.rb
@@ -0,0 +1,155 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+# verify that Action Cable is installed
+if defined?(ActionCable::Engine)
+ say "⏩ ActionCable::Engine is already loaded and in scope. Skipping"
+else
+ halt "ActionCable::Engine is not loaded, please add or uncomment `require \"action_cable/engine\"` to your `config/application.rb`"
+ return
+end
+
+return if pack_path_missing?
+
+# verify that the Action Cable pubsub config is created
+cable_config = Rails.root.join("config/cable.yml")
+
+if cable_config.exist?
+ say "⏩ config/cable.yml is already present. Skipping."
+else
+ inside "config" do
+ template "cable.yml"
+ end
+
+ say "✅ Created config/cable.yml"
+end
+
+# verify that the Action Cable pubsub is set to use redis in development
+yaml = YAML.safe_load(cable_config.read)
+app_name = Rails.application.class.module_parent.name.underscore
+
+if yaml["development"]["adapter"] == "redis"
+ say "⏩ config/cable.yml is already configured to use the redis adapter in development. Skipping."
+elsif yaml["development"]["adapter"] == "async"
+ yaml["development"] = {
+ "adapter" => "redis",
+ "url" => "<%= ENV.fetch(\"REDIS_URL\") { \"redis://localhost:6379/1\" } %>",
+ "channel_prefix" => "#{app_name}_development"
+ }
+ backup(cable_config) do
+ cable_config.write(yaml.to_yaml)
+ end
+ say "✅ config/cable.yml was updated to use the redis adapter in development"
+else
+ say "🤷 config/cable.yml should use the redis adapter - or something like it - in development. You have something else specified, and we trust that you know what you're doing."
+end
+
+if gemfile.match?(/gem ['"]redis['"]/)
+ say "⏩ redis gem is already present in Gemfile. Skipping."
+elsif Rails::VERSION::MAJOR >= 7
+ add_gem "redis@~> 5"
+else
+ add_gem "redis@~> 4"
+end
+
+# install action-cable-redis-backport gem if using Action Cable < 7.1
+unless ActionCable::VERSION::MAJOR >= 7 && ActionCable::VERSION::MINOR >= 1
+ if gemfile.match?(/gem ['"]action-cable-redis-backport['"]/)
+ say "⏩ action-cable-redis-backport gem is already present in Gemfile. Skipping."
+ else
+ add_gem "action-cable-redis-backport@~> 1"
+ end
+end
+
+# verify that the Action Cable channels folder and consumer class is available
+step_path = "/app/javascript/channels/"
+channels_path = Rails.root.join(entrypoint, "channels")
+consumer_src = fetch(step_path, "consumer.js.tt")
+consumer_path = channels_path / "consumer.js"
+index_src = fetch(step_path, "index.js.#{bundler}.tt")
+index_path = channels_path / "index.js"
+friendly_index_path = index_path.relative_path_from(Rails.root).to_s
+
+empty_directory channels_path unless channels_path.exist?
+
+copy_file(consumer_src, consumer_path) unless consumer_path.exist?
+
+if index_path.exist?
+ if index_path.read == index_src.read
+ say "⏩ #{friendly_index_path} is already present. Skipping."
+ else
+ backup(index_path) do
+ copy_file(index_src, index_path, verbose: false)
+ end
+ say "✅ #{friendly_index_path} has been updated"
+ end
+else
+ copy_file(index_src, index_path)
+ say "✅ #{friendly_index_path} has been created"
+end
+
+# import Action Cable channels into application pack
+channels_pattern = /import ['"](\.\.\/|\.\/)?channels['"]/
+channels_commented_pattern = /\s*\/\/\s*#{channels_pattern}/
+channel_import = "import \"#{prefix}channels\"\n"
+
+if pack.match?(channels_pattern)
+ if pack.match?(channels_commented_pattern)
+ proceed = if options.key? "uncomment"
+ options["uncomment"]
+ else
+ !no?("✨ Action Cable seems to be commented out in your application.js. Do you want to uncomment it? (Y/n)")
+ end
+
+ if proceed
+ # uncomment_lines only works with Ruby comments 🙄
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ channels_commented_pattern }
+ lines[lines.index(matches.last).to_i] = channel_import
+ pack_path.write lines.join
+ say "✅ Uncommented channels import in #{friendly_pack_path}"
+ else
+ say "🤷 your Action Cable channels are not being imported in your application.js. We trust that you have a reason for this."
+ end
+ else
+ say "⏩ channels are already being imported in #{friendly_pack_path}. Skipping."
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, channel_import
+ pack_path.write lines.join
+ say "✅ channels imported in #{friendly_pack_path}"
+end
+
+# create working copy of Action Cable initializer in tmp
+if action_cable_initializer_path.exist?
+ FileUtils.cp(action_cable_initializer_path, action_cable_initializer_working_path)
+
+ say "⏩ Action Cable initializer already exists. Skipping"
+else
+ # create Action Cable initializer if it doesn't already exist
+ create_file(action_cable_initializer_working_path, verbose: false) do
+ <<~RUBY
+ # frozen_string_literal: true
+
+ RUBY
+ end
+ say "✅ Action Cable initializer created"
+end
+
+# silence notoriously chatty Action Cable logs
+if action_cable_initializer_working_path.read.match?(/^[^#]*ActionCable.server.config.logger/)
+ say "⏩ Action Cable logger is already being silenced. Skipping"
+else
+ append_file(action_cable_initializer_working_path, verbose: false) do
+ <<~RUBY
+ ActionCable.server.config.logger = Logger.new(nil)
+
+ RUBY
+ end
+ say "✅ Action Cable logger silenced for performance and legibility"
+end
+
+complete_step :action_cable
diff --git a/lib/install/broadcaster.rb b/lib/install/broadcaster.rb
new file mode 100644
index 00000000..e56a239c
--- /dev/null
+++ b/lib/install/broadcaster.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+def needs_broadcaster?(path)
+ return false unless path.exist?
+
+ !path.readlines.index { |line| line =~ /^\s*include CableReady::Broadcaster/ }
+end
+
+channel_path = Rails.root.join("app/channels/application_cable/channel.rb")
+controller_path = Rails.root.join("app/controllers/application_controller.rb")
+job_path = Rails.root.join("app/jobs/application_job.rb")
+model_path = Rails.root.join(application_record_path)
+
+include_in_channel = needs_broadcaster?(channel_path)
+include_in_controller = needs_broadcaster?(controller_path)
+include_in_job = needs_broadcaster?(job_path)
+include_in_model = needs_broadcaster?(model_path)
+
+proceed = [include_in_channel, include_in_controller, include_in_job, include_in_model].reduce(:|)
+
+unless proceed
+ complete_step :broadcaster
+
+ puts "⏩ CableReady::Broadcaster already included in all files. Skipping."
+ return
+end
+
+proceed = if options.key? "broadcaster"
+ options["broadcaster"]
+else
+ !no?("✨ Make CableReady::Broadcaster available to channels, controllers, jobs and models? (Y/n)")
+end
+
+unless proceed
+ complete_step :broadcaster
+
+ puts "⏩ Skipping."
+ return
+end
+
+broadcaster_include = "\n include CableReady::Broadcaster\n"
+
+# include CableReady::Broadcaster in Action Cable Channel classes
+if include_in_channel
+ backup(channel_path) do
+ inject_into_file channel_path, broadcaster_include, after: /class (ApplicationCable::)?Channel < ActionCable::Channel::Base/, verbose: false
+ end
+
+ puts "✅ include CableReady::Broadcaster in ApplicationCable::Channel"
+else
+ puts "⏩ Not including CableReady::Broadcaster in ApplicationCable::Channel channels. Skipping."
+end
+
+# include CableReady::Broadcaster in Action Controller classes
+if include_in_controller
+ backup(controller_path) do
+ inject_into_class controller_path, "ApplicationController", broadcaster_include, verbose: false
+ end
+
+ puts "✅ include CableReady::Broadcaster in ApplicationController"
+else
+ puts "⏩ Not including CableReady::Broadcaster in ApplicationController. Skipping."
+end
+
+# include CableReady::Broadcaster in Active Job classes, if present
+
+if include_in_job
+ backup(job_path) do
+ inject_into_class job_path, "ApplicationJob", broadcaster_include, verbose: false
+ end
+
+ puts "✅ include CableReady::Broadcaster in ApplicationJob"
+else
+ puts "⏩ Not including CableReady::Broadcaster in ApplicationJob. Skipping."
+end
+
+# include CableReady::Broadcaster in Active Record model classes
+if include_in_model
+ backup(application_record_path) do
+ inject_into_class application_record_path, "ApplicationRecord", broadcaster_include, verbose: false
+ end
+
+ puts "✅ include CableReady::Broadcaster in ApplicationRecord"
+else
+ puts "⏩ Not including CableReady::Broadcaster in ApplicationRecord. Skipping"
+end
+
+complete_step :broadcaster
diff --git a/lib/install/bundle.rb b/lib/install/bundle.rb
new file mode 100644
index 00000000..7a851efc
--- /dev/null
+++ b/lib/install/bundle.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+hash = gemfile_hash
+
+# run bundle only when gems are waiting to be added or removed
+add = add_gem_list.exist? ? add_gem_list.readlines.map(&:chomp) : []
+remove = remove_gem_list.exist? ? remove_gem_list.readlines.map(&:chomp) : []
+
+if add.present? || remove.present?
+ lines = gemfile_path.readlines
+
+ remove.each do |name|
+ index = lines.index { |line| line =~ /gem ['"]#{name}['"]/ }
+ if index
+ if /^[^#]*gem ['"]#{name}['"]/.match?(lines[index])
+ lines[index] = "# #{lines[index]}"
+ say "✅ #{name} gem has been disabled"
+ else
+ say "⏩ #{name} gem is already disabled. Skipping."
+ end
+ end
+ end
+
+ add.each do |package|
+ matches = package.match(/(.+)@(.+)/)
+ name, version = matches[1], matches[2]
+
+ index = lines.index { |line| line =~ /gem ['"]#{name}['"]/ }
+ if index
+ if !lines[index].match(/^[^#]*gem ['"]#{name}['"].*#{version}['"]/)
+ lines[index] = "\ngem \"#{name}\", \"#{version}\"\n"
+ say "✅ #{name} gem has been installed"
+ else
+ say "⏩ #{name} gem is already installed. Skipping."
+ end
+ else
+ lines << "\ngem \"#{name}\", \"#{version}\"\n"
+ end
+ end
+
+ gemfile_path.write lines.join
+
+ bundle_command("install --quiet", "BUNDLE_IGNORE_MESSAGES" => "1") if hash != gemfile_hash
+else
+ say "⏩ No rubygems depedencies to install. Skipping."
+end
+
+FileUtils.cp(development_working_path, development_path)
+say "✅ development environment configuration installed"
+
+FileUtils.cp(action_cable_initializer_working_path, action_cable_initializer_path)
+say "✅ Action Cable initializer installed"
+
+complete_step :bundle
diff --git a/lib/install/compression.rb b/lib/install/compression.rb
new file mode 100644
index 00000000..fe264708
--- /dev/null
+++ b/lib/install/compression.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+initializer = action_cable_initializer_working_path.read
+
+if gemfile.match?(/gem ['"]permessage_deflate['"]/)
+ say "⏩ permessage_deflate already present in Gemfile. Skipping."
+else
+ add_gem "permessage_deflate@>= 0.1"
+end
+
+# add permessage_deflate config to Action Cable initializer
+if initializer.exclude? "PermessageDeflate.configure"
+ create_or_append(action_cable_initializer_working_path, verbose: false) do
+ <<~RUBY
+ module ActionCable
+ module Connection
+ class ClientSocket
+ alias_method :old_initialize, :initialize
+ def initialize(env, event_target, event_loop, protocols)
+ old_initialize(env, event_target, event_loop, protocols)
+ @driver.add_extension(
+ PermessageDeflate.configure(
+ level: Zlib::BEST_COMPRESSION,
+ max_window_bits: 13
+ )
+ )
+ end
+ end
+ end
+ end
+ RUBY
+ end
+
+ say "✅ Action Cable initializer patched to deflate websocket traffic"
+else
+ say "⏩ Action Cable initializer is already patched to deflate websocket traffic. Skipping."
+end
+
+complete_step :compression
diff --git a/lib/install/config.rb b/lib/install/config.rb
new file mode 100644
index 00000000..23443472
--- /dev/null
+++ b/lib/install/config.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+step_path = "/app/javascript/config/"
+index_src = fetch(step_path, "index.js.tt")
+index_path = config_path / "index.js"
+friendly_index_path = index_path.relative_path_from(Rails.root).to_s
+stimulus_reflex_src = fetch(step_path, "stimulus_reflex.js.tt")
+stimulus_reflex_path = config_path / "stimulus_reflex.js"
+friendly_stimulus_reflex_path = stimulus_reflex_path.relative_path_from(Rails.root).to_s
+cable_ready_src = fetch(step_path, "cable_ready.js.tt")
+cable_ready_path = config_path / "cable_ready.js"
+
+empty_directory config_path unless config_path.exist?
+
+if index_path.exist?
+ say "⏩ #{friendly_index_path} already exists. Skipping"
+else
+ backup(index_path, delete: true) do
+ copy_file(index_src, index_path)
+ end
+ say "✅ Created #{friendly_index_path}"
+end
+
+index_pattern = /import ['"](\.\.\/|\.\/)?config['"]/
+index_commented_pattern = /\s*\/\/\s*#{index_pattern}/
+index_import = "import \"#{prefix}config\"\n"
+
+if pack.match?(index_pattern)
+ if pack.match?(index_commented_pattern)
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ index_commented_pattern }
+ lines[lines.index(matches.last).to_i] = index_import
+ pack_path.write lines.join
+
+ say "✅ Uncommented StimulusReflex and CableReady configs imports in #{friendly_pack_path}"
+ else
+ say "⏩ StimulusReflex and CableReady configs are already being imported in #{friendly_pack_path}. Skipping"
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, index_import
+ pack_path.write lines.join
+
+ say "✅ StimulusReflex and CableReady configs will be imported in #{friendly_pack_path}"
+end
+
+# create entrypoint/config/cable_ready.js and make sure it's imported in application.js
+copy_file(cable_ready_src, cable_ready_path) unless cable_ready_path.exist?
+
+# create entrypoint/config/stimulus_reflex.js and make sure it's imported in application.js
+copy_file(stimulus_reflex_src, stimulus_reflex_path) unless stimulus_reflex_path.exist?
+
+if stimulus_reflex_path.read.include?("StimulusReflex.debug =")
+ say "⏩ Development environment options are already set in #{friendly_stimulus_reflex_path}. Skipping"
+else
+ if ["webpacker", "shakapacker"].include?(bundler)
+ append_file(stimulus_reflex_path, <<~JS, verbose: false)
+
+ if (process.env.RAILS_ENV === 'development') {
+ StimulusReflex.debug = true
+ }
+ JS
+ elsif bundler == "vite"
+ append_file(stimulus_reflex_path, <<~JS, verbose: false) unless stimulus_reflex_path.read.include?("StimulusReflex.debug")
+
+ if (import.meta.env.MODE === "development") {
+ StimulusReflex.debug = true
+ }
+ JS
+ else
+ append_file(stimulus_reflex_path, <<~JS, verbose: false)
+
+ // consider removing these options in production
+ StimulusReflex.debug = true
+ // end remove
+ JS
+ end
+
+ say "✅ Set useful development environment options in #{friendly_stimulus_reflex_path}"
+end
+
+complete_step :config
diff --git a/lib/install/development.rb b/lib/install/development.rb
new file mode 100644
index 00000000..d188e9b3
--- /dev/null
+++ b/lib/install/development.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+# mutate working copy of development.rb to avoid bundle alerts
+FileUtils.cp(development_path, development_working_path)
+
+# add default_url_options to development.rb for Action Mailer
+if defined?(ActionMailer)
+ lines = development_working_path.readlines
+ if lines.find { |line| line.include?("config.action_mailer.default_url_options") }
+ say "⏩ Action Mailer default_url_options already defined. Skipping."
+ else
+ index = lines.index { |line| line =~ /^Rails.application.configure do/ }
+ lines.insert index + 1, " config.action_mailer.default_url_options = {host: \"localhost\", port: 3000}\n\n"
+ development_working_path.write lines.join
+
+ say "✅ Action Mailer default_url_options defined"
+ end
+end
+
+# add default_url_options to development.rb for Action Controller
+lines = development_working_path.readlines
+if lines.find { |line| line.include?("config.action_controller.default_url_options") }
+ say "⏩ Action Controller default_url_options already defined. Skipping."
+else
+ index = lines.index { |line| line =~ /^Rails.application.configure do/ }
+ lines.insert index + 1, " config.action_controller.default_url_options = {host: \"localhost\", port: 3000}\n"
+ development_working_path.write lines.join
+
+ say "✅ Action Controller default_url_options defined"
+end
+
+# halt with instructions if using cookie store, otherwise, nudge towards Redis
+lines = development_working_path.readlines
+
+if (index = lines.index { |line| line =~ /^[^#]*config.session_store/ })
+ if /^[^#]*cookie_store/.match?(lines[index])
+ write_redis_recommendation(development_working_path, lines, index, gemfile_path)
+ halt "StimulusReflex does not support session cookies. See https://docs.stimulusreflex.com/hello-world/setup#session-storage"
+ return
+ elsif /^[^#]*redis_session_store/.match?(lines[index])
+ say "⏩ Already using redis-session-store for session storage. Skipping."
+ else
+ write_redis_recommendation(development_working_path, lines, index, gemfile_path)
+ say "🤷 We recommend using redis-session-store for session storage. See https://docs.stimulusreflex.com/hello-world/setup#session-storage"
+ end
+# no session store defined, so let's opt-in to redis-session-store
+else
+ # add redis-session-store to Gemfile
+ if !gemfile.match?(/gem ['"]redis-session-store['"]/)
+ if ActionCable::VERSION::MAJOR >= 7
+ add_gem "redis-session-store@~> 0.11.5"
+ else
+ add_gem "redis-session-store@0.11.4"
+ end
+ end
+
+ index = lines.index { |line| line =~ /^Rails.application.configure do/ }
+ lines.insert index + 1, <<~RUBY
+
+ config.session_store :redis_session_store,
+ serializer: :json,
+ on_redis_down: ->(*a) { Rails.logger.error("Redis down! \#{a.inspect}") },
+ redis: {
+ expire_after: 120.minutes,
+ key_prefix: "session:",
+ url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
+ }
+ RUBY
+ development_working_path.write lines.join
+ say "✅ Using redis-session-store for session storage"
+end
+
+# switch to redis for caching if using memory store, otherwise nudge with a comment
+lines = development_working_path.readlines
+
+if (index = lines.index { |line| line =~ /^[^#]*config.cache_store = :memory_store/ })
+ lines[index] = <<~RUBY
+ config.cache_store = :redis_cache_store, {
+ url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
+ }
+ RUBY
+ development_working_path.write lines.join
+ say "✅ Using Redis for caching"
+elsif lines.index { |line| line =~ /^[^#]*config.cache_store = :redis_cache_store/ }
+ say "⏩ Already using Redis for caching. Skipping."
+else
+ if !lines.index { |line| line.include?("We couldn't identify your cache store") }
+ lines.insert find_index(lines), <<~RUBY
+
+ # We couldn't identify your cache store, but recommend using Redis:
+
+ # config.cache_store = :redis_cache_store, {
+ # url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
+ # }
+ RUBY
+ development_working_path.write lines.join
+ end
+ say "🤷 We couldn't identify your cache store, but recommend using Redis. See https://docs.stimulusreflex.com/appendices/deployment#use-redis-as-your-cache-store"
+end
+
+if Rails.root.join("tmp", "caching-dev.txt").exist?
+ say "⏩ Already caching in development. Skipping."
+else
+ system "rails dev:cache"
+ say "✅ Enabled caching in development"
+end
+
+complete_step :development
diff --git a/lib/install/esbuild.rb b/lib/install/esbuild.rb
new file mode 100644
index 00000000..fdec8d17
--- /dev/null
+++ b/lib/install/esbuild.rb
@@ -0,0 +1,114 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+# verify that all critical dependencies are up to date; if not, queue for later
+lines = package_json.readlines
+
+if !lines.index { |line| line =~ /^\s*["']esbuild-rails["']: ["']\^1.0.3["']/ }
+ add_package "esbuild-rails@^1.0.3"
+else
+ say "⏩ esbuild-rails npm package is already present. Skipping."
+end
+
+# copy esbuild.config.mjs to app root
+esbuild_src = fetch("/", "esbuild.config.mjs.tt")
+esbuild_path = Rails.root.join("esbuild.config.mjs")
+
+if esbuild_path.exist?
+ if esbuild_path.read == esbuild_src.read
+ say "⏩ esbuild.config.mjs already present in app root. Skipping."
+ else
+ backup(esbuild_path) do
+ template(esbuild_src, esbuild_path, verbose: false, entrypoint: entrypoint)
+ end
+ say "✅ updated esbuild.config.mjs in app root"
+ end
+else
+ template(esbuild_src, esbuild_path, entrypoint: entrypoint)
+ say "✅ Created esbuild.config.mjs in app root"
+end
+
+step_path = "/app/javascript/controllers/"
+application_controller_src = fetch(step_path, "application_controller.js.tt")
+application_controller_path = controllers_path / "application_controller.js"
+application_js_src = fetch(step_path, "application.js.tt")
+application_js_path = controllers_path / "application.js"
+index_src = fetch(step_path, "index.js.esbuild.tt")
+index_path = controllers_path / "index.js"
+friendly_index_path = index_path.relative_path_from(Rails.root).to_s
+
+# create entrypoint/controllers, if necessary
+empty_directory controllers_path unless controllers_path.exist?
+
+# copy application_controller.js, if necessary
+copy_file(application_controller_src, application_controller_path) unless application_controller_path.exist?
+
+# configure Stimulus application superclass to import Action Cable consumer
+friendly_application_js_path = application_js_path.relative_path_from(Rails.root).to_s
+
+if application_js_path.exist?
+ backup(application_js_path) do
+ if application_js_path.read.include?("import consumer")
+ say "⏩ #{friendly_application_js_path} is already present. Skipping."
+ else
+ inject_into_file application_js_path, "import consumer from \"../channels/consumer\"\n", after: "import { Application } from \"@hotwired/stimulus\"\n", verbose: false
+ inject_into_file application_js_path, "application.consumer = consumer\n", after: "application.debug = false\n", verbose: false
+ say "✅ #{friendly_application_js_path} has been updated to import the Action Cable consumer"
+ end
+ end
+else
+ copy_file(application_js_src, application_js_path)
+ say "✅ #{friendly_application_js_path} has been created"
+end
+
+if index_path.exist?
+ if index_path.read == index_src.read
+ say "⏩ #{friendly_index_path} already present. Skipping."
+ else
+ backup(index_path, delete: true) do
+ copy_file(index_src, index_path, verbose: false)
+ end
+
+ say "✅ #{friendly_index_path} has been updated"
+ end
+else
+ copy_file(index_src, index_path)
+ say "✅ #{friendly_index_path} has been created"
+end
+
+controllers_pattern = /import ['"].\/controllers['"]/
+controllers_commented_pattern = /\s*\/\/\s*#{controllers_pattern}/
+
+if pack.match?(controllers_pattern)
+ if pack.match?(controllers_commented_pattern)
+ proceed = if options.key? "uncomment"
+ options["uncomment"]
+ else
+ !no?("✨ Stimulus seems to be commented out in your application.js. Do you want to import your controllers? (Y/n)")
+ end
+
+ if proceed
+ # uncomment_lines only works with Ruby comments 🙄
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ controllers_commented_pattern }
+ lines[lines.index(matches.last).to_i] = "import \".\/controllers\"\n" # standard:disable Style/RedundantStringEscape
+ pack_path.write lines.join
+ say "✅ Uncommented Stimulus controllers import in #{friendly_pack_path}"
+ else
+ say "🤷 your Stimulus controllers are not being imported in your application.js. We trust that you have a reason for this."
+ end
+ else
+ say "⏩ Stimulus controllers are already being imported in #{friendly_pack_path}. Skipping."
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, "import \".\/controllers\"\n" # standard:disable Style/RedundantStringEscape
+ pack_path.write lines.join
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+end
+
+complete_step :esbuild
diff --git a/lib/install/example.rb b/lib/install/example.rb
new file mode 100644
index 00000000..8f0f2237
--- /dev/null
+++ b/lib/install/example.rb
@@ -0,0 +1,22 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+proceed = false
+if !Rails.root.join("app/reflexes/example_reflex.rb").exist?
+ proceed = if options.key? "example"
+ options["example"]
+ else
+ !no?("✨ Generate an example Reflex with a quick demo? You can remove it later with a single command. (Y/n)")
+ end
+else
+ say "⏩ app/reflexes/example_reflex.rb already exists."
+end
+
+if proceed
+ generate("stimulus_reflex", "example")
+else
+ say "⏩ Skipping."
+end
+
+complete_step :example
diff --git a/lib/install/importmap.rb b/lib/install/importmap.rb
new file mode 100644
index 00000000..c954bbbf
--- /dev/null
+++ b/lib/install/importmap.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+if !importmap_path.exist?
+ halt "#{friendly_importmap_path} is missing. You need a valid importmap config file to proceed."
+ return
+end
+
+importmap = importmap_path.read
+
+backup(importmap_path) do
+ if !importmap.include?("pin_all_from \"#{entrypoint}/controllers\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin_all_from "#{entrypoint}/controllers", under: "controllers"
+ RUBY
+ say "✅ pin_all_from controllers"
+ else
+ say "⏩ pin_all_from controllers already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin_all_from \"#{entrypoint}/channels\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin_all_from "#{entrypoint}/channels", under: "channels"
+ RUBY
+ say "✅ pin_all_from channels"
+ else
+ say "⏩ pin_all_from channels already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin_all_from \"#{entrypoint}/config\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin_all_from "#{entrypoint}/config", under: "config"
+ RUBY
+ say "✅ pin_all_from config"
+ else
+ say "⏩ pin_all_from config already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin \"@rails/actioncable\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "@rails/actioncable", to: "actioncable.esm.js", preload: true
+ RUBY
+ say "✅ pin @rails/actioncable"
+ else
+ say "⏩ @rails/actioncable already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin \"@hotwired/stimulus\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
+ RUBY
+ say "✅ pin @hotwired/stimulus"
+ else
+ say "⏩ @hotwired/stimulus already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin \"morphdom\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "morphdom", to: "https://ga.jspm.io/npm:morphdom@2.6.1/dist/morphdom.js", preload: true
+ RUBY
+ say "✅ pin morphdom"
+ else
+ say "⏩ morphdom already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin \"cable_ready\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "cable_ready", to: "cable_ready.min.js", preload: true
+ RUBY
+ say "✅ pin cable_ready"
+ else
+ say "⏩ cable_ready already pinned. Skipping."
+ end
+
+ if !importmap.include?("pin \"stimulus_reflex\"")
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "stimulus_reflex", to: "stimulus_reflex.min.js", preload: true
+ RUBY
+ say "✅ pin stimulus_reflex"
+ else
+ say "⏩ stimulus_reflex already pinned. Skipping."
+ end
+end
+
+application_controller_src = fetch("/", "app/javascript/controllers/application_controller.js.tt")
+application_controller_path = controllers_path / "application_controller.js"
+application_js_src = fetch("/", "app/javascript/controllers/application.js.tt")
+application_js_path = controllers_path / "application.js"
+index_src = fetch("/", "app/javascript/controllers/index.js.importmap.tt")
+index_path = controllers_path / "index.js"
+
+# create entrypoint/controllers, as well as the index, application and application_controller
+empty_directory controllers_path unless controllers_path.exist?
+
+copy_file(application_controller_src, application_controller_path) unless application_controller_path.exist?
+
+# configure Stimulus application superclass to import Action Cable consumer
+backup(application_js_path) do
+ if application_js_path.exist?
+ friendly_application_js_path = application_js_path.relative_path_from(Rails.root).to_s
+ if application_js_path.read.include?("import consumer")
+ say "⏩ #{friendly_application_js_path} is present. Skipping."
+ else
+ inject_into_file application_js_path, "import consumer from \"../channels/consumer\"\n", after: "import { Application } from \"@hotwired/stimulus\"\n", verbose: false
+ inject_into_file application_js_path, "application.consumer = consumer\n", after: "application.debug = false\n", verbose: false
+ say "✅ #{friendly_application_js_path} has been updated to import the Action Cable consumer"
+ end
+ else
+ copy_file(application_js_src, application_js_path)
+ say "✅ #{friendly_application_js_path} has been created"
+ end
+end
+
+if index_path.exist?
+ friendly_index_path = index_path.relative_path_from(Rails.root).to_s
+
+ if index_path.read == index_src.read
+ say "⏩ #{friendly_index_path} is present. Skipping"
+ else
+ backup(index_path, delete: true) do
+ copy_file(index_src, index_path, verbose: false)
+ end
+ say "✅ #{friendly_index_path} has been updated"
+ end
+else
+ copy_file(index_src, index_path)
+ say "✅ #{friendly_index_path} has been created."
+end
+
+complete_step :importmap
diff --git a/lib/install/initializers.rb b/lib/install/initializers.rb
new file mode 100644
index 00000000..ba08e1c4
--- /dev/null
+++ b/lib/install/initializers.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+sr_initializer_src = fetch("/", "config/initializers/stimulus_reflex.rb")
+sr_initializer_path = Rails.root.join("config/initializers/stimulus_reflex.rb")
+
+cr_initializer_src = fetch("/", "config/initializers/cable_ready.rb")
+cr_initializer_path = Rails.root.join("config/initializers/cable_ready.rb")
+
+if !sr_initializer_path.exist?
+ copy_file(sr_initializer_src, sr_initializer_path, verbose: false)
+ say "✅ StimulusReflex initializer created at config/initializers/stimulus_reflex.rb"
+else
+ say "⏩ config/initializers/stimulus_reflex.rb already exists. Skipping."
+end
+
+if !cr_initializer_path.exist?
+ copy_file(cr_initializer_src, cr_initializer_path, verbose: false)
+ say "✅ CableReady initializer created at config/initializers/cable_ready.rb"
+else
+ say "⏩ config/initializers/cable_ready.rb already exists. Skipping."
+end
+
+complete_step :initializers
diff --git a/lib/install/mrujs.rb b/lib/install/mrujs.rb
new file mode 100644
index 00000000..28c9f518
--- /dev/null
+++ b/lib/install/mrujs.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+mrujs_path = config_path / "mrujs.js"
+
+proceed = false
+
+if !File.exist?(mrujs_path)
+ proceed = if options.key? "mrujs"
+ options["mrujs"]
+ else
+ !no?("✨ Would you like to install and enable mrujs? It's a modern, drop-in replacement for rails-ujs (Y/n)")
+ end
+end
+
+if proceed
+ if bundler == "importmap"
+
+ if !importmap_path.exist?
+ halt "#{friendly_importmap_path} is missing. You need a valid importmap config file to proceed."
+ return
+ end
+
+ importmap = importmap_path.read
+
+ if importmap.include?("pin \"mrujs\"")
+ say "⏩ mrujs already pinned. Skipping."
+ else
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "mrujs", to: "https://ga.jspm.io/npm:mrujs@0.10.1/dist/index.module.js"
+ RUBY
+ say "✅ pin mrujs"
+ end
+
+ if importmap.include?("pin \"mrujs/plugins\"")
+ say "⏩ mrujs/plugins already pinned. Skipping."
+ else
+ append_file(importmap_path, <<~RUBY, verbose: false)
+ pin "mrujs/plugins", to: "https://ga.jspm.io/npm:mrujs@0.10.1/plugins/dist/plugins.module.js"
+ RUBY
+ say "✅ pin mrujs/plugins"
+ end
+ else
+ # queue mrujs for installation
+ if package_json.read.include?('"mrujs":')
+ say "⏩ mrujs already present. Skipping."
+ else
+ add_package "mrujs@^0.10.1"
+ end
+
+ # queue @rails/ujs for removal
+ if package_json.read.include?('"@rails/ujs":')
+ drop_package "@rails/ujs"
+ else
+ say "⏩ @rails/ujs not present. Skipping."
+ end
+ end
+
+ step_path = "/app/javascript/config/"
+ mrujs_src = fetch(step_path, "mrujs.js.tt")
+
+ # create entrypoint/config/mrujs.js if necessary
+ copy_file(mrujs_src, mrujs_path) unless mrujs_path.exist?
+
+ # import mrujs config in entrypoint/config/index.js
+ index_path = config_path / "index.js"
+ index = index_path.read
+ friendly_index_path = index_path.relative_path_from(Rails.root).to_s
+ mrujs_pattern = /import ['"].\/mrujs['"]/
+ mrujs_import = "import '.\/mrujs'\n" # standard:disable Style/RedundantStringEscape
+
+ if index.match?(mrujs_pattern)
+ say "⏩ mrujs alredy imported in #{friendly_index_path}. Skipping."
+ else
+ append_file(index_path, mrujs_import, verbose: false)
+ say "✅ mrujs imported in #{friendly_index_path}"
+ end
+
+ # remove @rails/ujs from application.js
+ rails_ujs_pattern = /import Rails from ['"]@rails\/ujs['"]/
+
+ lines = pack_path.readlines
+ if lines.index { |line| line =~ rails_ujs_pattern }
+ gsub_file pack_path, rails_ujs_pattern, "", verbose: false
+ say "✅ @rails/ujs removed from #{friendly_pack_path}"
+ else
+ say "⏩ @rails/ujs not present in #{friendly_pack_path}. Skipping."
+ end
+
+ # set Action View to generate remote forms when using form_with
+ application_path = Rails.root.join("config/application.rb")
+ application_pattern = /^[^#]*config\.action_view\.form_with_generates_remote_forms = true/
+ defaults_pattern = /config\.load_defaults \d\.\d/
+
+ lines = application_path.readlines
+ backup(application_path) do
+ if !lines.index { |line| line =~ application_pattern }
+ if (index = lines.index { |line| line =~ /^[^#]*#{defaults_pattern}/ })
+ gsub_file application_path, /\s*#{defaults_pattern}\n/, verbose: false do
+ <<-RUBY
+ \n#{lines[index]}
+ # form_with helper will generate remote forms by default (mrujs)
+ config.action_view.form_with_generates_remote_forms = true
+ RUBY
+ end
+ else
+ insert_into_file application_path, after: "class Application < Rails::Application" do
+ <<-RUBY
+
+ # form_with helper will generate remote forms by default (mrujs)
+ config.action_view.form_with_generates_remote_forms = true
+ RUBY
+ end
+ end
+ end
+ say "✅ form_with_generates_remote_forms set to true in config/application.rb"
+ end
+
+ # remove turbolinks from Gemfile because it's incompatible with mrujs (and unnecessary)
+ turbolinks_pattern = /^[^#]*gem ["']turbolinks["']/
+
+ lines = gemfile_path.readlines
+ if lines.index { |line| line =~ turbolinks_pattern }
+ remove_gem :turbolinks
+ else
+ say "⏩ turbolinks is not present in Gemfile. Skipping."
+ end
+end
+
+complete_step :mrujs
diff --git a/lib/install/npm_packages.rb b/lib/install/npm_packages.rb
new file mode 100644
index 00000000..d579b249
--- /dev/null
+++ b/lib/install/npm_packages.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+lines = package_json.readlines
+
+if !lines.index { |line| line =~ /^\s*["']cable_ready["']: ["'].*#{cr_npm_version}["']/ }
+ add_package "cable_ready@#{cr_npm_version}"
+else
+ say "⏩ cable_ready npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']stimulus_reflex["']: ["'].*#{sr_npm_version}["']/ }
+ add_package "stimulus_reflex@#{sr_npm_version}"
+else
+ say "⏩ stimulus_reflex npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']@hotwired\/stimulus["']:/ }
+ add_package "@hotwired/stimulus@^3.2"
+else
+ say "⏩ @hotwired/stimulus npm package is already present. Skipping."
+end
+
+complete_step :npm_packages
diff --git a/lib/install/reflexes.rb b/lib/install/reflexes.rb
new file mode 100644
index 00000000..64ea2feb
--- /dev/null
+++ b/lib/install/reflexes.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+reflexes_path = Rails.root.join("app/reflexes")
+step_path = "/app/reflexes/"
+application_reflex_path = reflexes_path / "application_reflex.rb"
+application_reflex_src = fetch(step_path, "application_reflex.rb.tt")
+
+# verify app/reflexes exists and create if necessary
+if reflexes_path.exist?
+ say "⏩ app/reflexes directory already exists. Skipping."
+else
+ empty_directory reflexes_path
+ say "✅ Created app/reflexes directory"
+end
+
+if application_reflex_path.exist?
+ say "⏩ app/reflexes/application_reflex.rb is alredy present. Skipping."
+else
+ copy_file application_reflex_src, application_reflex_path
+ say "✅ Created app/reflexes/application_reflex.rb"
+end
+
+complete_step :reflexes
diff --git a/lib/install/shakapacker.rb b/lib/install/shakapacker.rb
new file mode 100644
index 00000000..2a046d57
--- /dev/null
+++ b/lib/install/shakapacker.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+# verify that all critical dependencies are up to date; if not, queue for later
+lines = package_json.readlines
+
+if !lines.index { |line| line =~ /^\s*["']@hotwired\/stimulus-webpack-helpers["']: ["']\^1.0.1["']/ }
+ add_package "@hotwired/stimulus-webpack-helpers@^1.0.1"
+else
+ say "⏩ @hotwired/stimulus-webpack-helpers npm package is already present. Skipping."
+end
+
+step_path = "/app/javascript/controllers/"
+# controller_templates_path = File.expand_path(template_src + "/app/javascript/controllers", File.join(File.dirname(__FILE__)))
+application_controller_src = fetch(step_path, "application_controller.js.tt")
+application_controller_path = controllers_path / "application_controller.js"
+application_js_src = fetch(step_path, "application.js.tt")
+application_js_path = controllers_path / "application.js"
+index_src = fetch(step_path, "index.js.shakapacker.tt")
+index_path = controllers_path / "index.js"
+
+# create entrypoint/controllers, as well as the index, application and application_controller
+empty_directory controllers_path unless controllers_path.exist?
+
+copy_file(application_controller_src, application_controller_path) unless application_controller_path.exist?
+copy_file(application_js_src, application_js_path) unless application_js_path.exist?
+copy_file(index_src, index_path) unless index_path.exist?
+
+controllers_pattern = /import ['"]controllers['"]/
+controllers_commented_pattern = /\s*\/\/\s*#{controllers_pattern}/
+
+if pack.match?(controllers_pattern)
+ if pack.match?(controllers_commented_pattern)
+ proceed = if options.key? "uncomment"
+ options["uncomment"]
+ else
+ !no?("✨ Do you want to import your Stimulus controllers in application.js? (Y/n)")
+ end
+
+ if proceed
+ # uncomment_lines only works with Ruby comments 🙄
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ controllers_commented_pattern }
+ lines[lines.index(matches.last).to_i] = "import \"controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+ else
+ say "🤷 your Stimulus controllers are not being imported in your application.js. We trust that you have a reason for this."
+ end
+ else
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, "import \"controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+end
+
+complete_step :shakapacker
diff --git a/lib/install/spring.rb b/lib/install/spring.rb
new file mode 100644
index 00000000..0ed753db
--- /dev/null
+++ b/lib/install/spring.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+spring_pattern = /^[^#]*gem ["']spring["']/
+
+proceed = false
+lines = gemfile_path.readlines
+
+if lines.index { |line| line =~ spring_pattern }
+ proceed = if options.key? "spring"
+ options["spring"]
+ else
+ !no?("✨ Would you like to disable the spring gem? \nIt's been removed from Rails 7, and is the frequent culprit behind countless mystery bugs. (Y/n)")
+ end
+else
+ say "⏩ Spring is not installed."
+end
+
+if proceed
+ spring_watcher_pattern = /^[^#]*gem ["']spring-watcher-listen["']/
+ bin_rails_pattern = /^[^#]*load File.expand_path\("spring", __dir__\)/
+
+ if (index = lines.index { |line| line =~ spring_pattern })
+ remove_gem :spring
+
+ bin_spring = Rails.root.join("bin/spring")
+ if bin_spring.exist?
+ run "bin/spring binstub --remove --all"
+ say "✅ Removed spring binstubs"
+ end
+
+ bin_rails = Rails.root.join("bin/rails")
+ bin_rails_content = bin_rails.readlines
+ if (index = bin_rails_content.index { |line| line =~ bin_rails_pattern })
+ backup(bin_rails) do
+ bin_rails_content[index] = "# #{bin_rails_content[index]}"
+ bin_rails.write bin_rails_content.join
+ end
+ say "✅ Removed spring from bin/rails"
+ end
+ create_file "tmp/stimulus_reflex_installer/kill_spring", verbose: false
+ else
+ say "✅ spring has been successfully removed"
+ end
+
+ if lines.index { |line| line =~ spring_watcher_pattern }
+ remove_gem "spring-watcher-listen"
+ end
+else
+ say "⏩ Skipping."
+end
+
+complete_step :spring
diff --git a/lib/install/updatable.rb b/lib/install/updatable.rb
new file mode 100644
index 00000000..366eaac9
--- /dev/null
+++ b/lib/install/updatable.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+if application_record_path.exist?
+ lines = application_record_path.readlines
+
+ if !lines.index { |line| line =~ /^\s*include CableReady::Updatable/ }
+ proceed = if options.key? "updatable"
+ options["updatable"]
+ else
+ !no?("✨ Include CableReady::Updatable in Active Record model classes? (Y/n)")
+ end
+
+ unless proceed
+ complete_step :updatable
+
+ puts "⏩ Skipping."
+ return
+ end
+
+ index = lines.index { |line| line.include?("class ApplicationRecord < ActiveRecord::Base") }
+ lines.insert index + 1, " include CableReady::Updatable\n"
+ application_record_path.write lines.join
+
+ say "✅ included CableReady::Updatable in ApplicationRecord"
+ else
+ say "⏩ CableReady::Updatable has already been included in Active Record model classes. Skipping."
+ end
+else
+ say "⏩ ApplicationRecord doesn't exist. Skipping."
+end
+
+complete_step :updatable
diff --git a/lib/install/vite.rb b/lib/install/vite.rb
new file mode 100644
index 00000000..56d67fcc
--- /dev/null
+++ b/lib/install/vite.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+# verify that all critical dependencies are up to date; if not, queue for later
+lines = package_json.readlines
+
+if !lines.index { |line| line =~ /^\s*["']stimulus-vite-helpers["']: ["']\^3["']/ }
+ add_package "stimulus-vite-helpers@^3"
+else
+ say "⏩ @stimulus-vite-helpers npm package is already present. Skipping."
+end
+
+step_path = "/app/javascript/controllers/"
+application_controller_src = fetch(step_path, "application_controller.js.tt")
+application_controller_path = controllers_path / "application_controller.js"
+application_js_src = fetch(step_path, "application.js.tt")
+application_js_path = controllers_path / "application.js"
+index_src = fetch(step_path, "index.js.vite.tt")
+index_path = controllers_path / "index.js"
+
+# create entrypoint/controllers, as well as the index, application and application_controller
+empty_directory controllers_path unless controllers_path.exist?
+
+copy_file(application_controller_src, application_controller_path) unless application_controller_path.exist?
+copy_file(application_js_src, application_js_path) unless application_js_path.exist?
+copy_file(index_src, index_path) unless index_path.exist?
+
+controllers_pattern = /import ['"](\.\.\/)?controllers['"]/
+controllers_commented_pattern = /\s*\/\/\s*#{controllers_pattern}/
+prefix = "..\/" # standard:disable Style/RedundantStringEscape
+
+if pack.match?(controllers_pattern)
+ if pack.match?(controllers_commented_pattern)
+ proceed = if options.key? "uncomment"
+ options["uncomment"]
+ else
+ !no?("✨ Do you want to import your Stimulus controllers in application.js? (Y/n)")
+ end
+
+ if proceed
+ # uncomment_lines only works with Ruby comments 🙄
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ controllers_commented_pattern }
+ lines[lines.index(matches.last).to_i] = "import \"#{prefix}controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Uncommented Stimulus controllers import in #{friendly_pack_path}"
+ else
+ say "🤷 your Stimulus controllers are not being imported in your application.js. We trust that you have a reason for this."
+ end
+ else
+ say "⏩ Stimulus controllers are already being imported in #{friendly_pack_path}. Skipping."
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, "import \"#{prefix}controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+end
+
+complete_step :vite
diff --git a/lib/install/webpacker.rb b/lib/install/webpacker.rb
new file mode 100644
index 00000000..2e52f245
--- /dev/null
+++ b/lib/install/webpacker.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+return if pack_path_missing?
+
+# verify that all critical dependencies are up to date; if not, queue for later
+lines = package_json.readlines
+if !lines.index { |line| line =~ /^\s*["']webpack["']: ["']\^4.46.0["']/ }
+ add_package "webpack@^4.46.0"
+else
+ say "⏩ webpack npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']webpack-cli["']: ["']\^3.3.12["']/ }
+ add_package "webpack-cli@^3.3.12"
+else
+ say "⏩ webpack-cli npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']@rails\/webpacker["']: ["']\^5.4.3["']/ }
+ add_package "@rails/webpacker@^5.4.3"
+else
+ say "⏩ @rails/webpacker npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']@hotwired\/stimulus-webpack-helpers["']: ["']\^1.0.1["']/ }
+ add_package "@hotwired/stimulus-webpack-helpers@^1.0.1"
+else
+ say "⏩ @hotwired/stimulus-webpack-helpers npm package is already present. Skipping."
+end
+
+if !lines.index { |line| line =~ /^\s*["']webpack-dev-server["']: ["']\^3.11.3["']/ }
+ add_dev_package "webpack-dev-server@^3.11.3"
+else
+ say "⏩ @webpack-dev-server is already present. Skipping."
+end
+
+step_path = "/app/javascript/controllers/"
+application_controller_src = fetch(step_path, "application_controller.js.tt")
+application_controller_path = controllers_path / "application_controller.js"
+application_js_src = fetch(step_path, "application.js.tt")
+application_js_path = controllers_path / "application.js"
+index_src = fetch(step_path, "index.js.webpacker.tt")
+index_path = controllers_path / "index.js"
+
+# create entrypoint/controllers, as well as the index, application and application_controller
+empty_directory controllers_path unless controllers_path.exist?
+
+copy_file(application_controller_src, application_controller_path) unless application_controller_path.exist?
+# webpacker 5.4 did not colloquially feature a controllers/application.js file
+copy_file(application_js_src, application_js_path) unless application_js_path.exist?
+copy_file(index_src, index_path) unless index_path.exist?
+
+controllers_pattern = /import ['"]controllers['"]/
+controllers_commented_pattern = /\s*\/\/\s*#{controllers_pattern}/
+
+if pack.match?(controllers_pattern)
+ if pack.match?(controllers_commented_pattern)
+ proceed = if options.key? "uncomment"
+ options["uncomment"]
+ else
+ !no?("✨ Do you want to import your Stimulus controllers in application.js? (Y/n)")
+ end
+
+ if proceed
+ # uncomment_lines only works with Ruby comments 🙄
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ controllers_commented_pattern }
+ lines[lines.index(matches.last).to_i] = "import \"controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Uncommented Stimulus controllers import in #{friendly_pack_path}"
+ else
+ say "🤷 your Stimulus controllers are not being imported in your application.js. We trust that you have a reason for this."
+ end
+ else
+ say "⏩ Stimulus controllers are already being imported in #{friendly_pack_path}. Skipping."
+ end
+else
+ lines = pack_path.readlines
+ matches = lines.select { |line| line =~ /^import / }
+ lines.insert lines.index(matches.last).to_i + 1, "import \"controllers\"\n"
+ pack_path.write lines.join
+ say "✅ Stimulus controllers imported in #{friendly_pack_path}"
+end
+
+# ensure webpacker is installed in the Gemfile
+add_gem "webpacker@5.4.3"
+
+complete_step :webpacker
diff --git a/lib/install/yarn.rb b/lib/install/yarn.rb
new file mode 100644
index 00000000..af7754dc
--- /dev/null
+++ b/lib/install/yarn.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+require "stimulus_reflex/installer"
+
+if !package_json.exist?
+ say "⏩ No package.json file found. Skipping."
+
+ return
+end
+
+# run yarn install only when packages are waiting to be added or removed
+add = package_list.exist? ? package_list.readlines.map(&:chomp) : []
+dev = dev_package_list.exist? ? dev_package_list.readlines.map(&:chomp) : []
+drop = drop_package_list.exist? ? drop_package_list.readlines.map(&:chomp) : []
+
+json = JSON.parse(package_json.read)
+
+if add.present? || dev.present? || drop.present?
+
+ add.each do |package|
+ matches = package.match(/(.+)@(.+)/)
+ name, version = matches[1], matches[2]
+ json["dependencies"] = {} unless json["dependencies"]
+ json["dependencies"][name] = version
+ end
+
+ dev.each do |package|
+ matches = package.match(/(.+)@(.+)/)
+ name, version = matches[1], matches[2]
+ json["devDependencies"] = {} unless json["devDependencies"]
+ json["devDependencies"][name] = version
+ end
+
+ drop.each do |package|
+ json["dependencies"].delete(package)
+ json["devDependencies"].delete(package)
+ end
+
+ package_json.write JSON.pretty_generate(json)
+
+ system "yarn install --silent"
+else
+ say "⏩ No yarn depdencies to add or remove. Skipping."
+end
+
+if bundler == "esbuild" && json["scripts"]["build"] != "node esbuild.config.mjs"
+ json["scripts"]["build:default"] = json["scripts"]["build"]
+ json["scripts"]["build"] = "node esbuild.config.mjs"
+ package_json.write JSON.pretty_generate(json)
+ say "✅ Your yarn build script has been updated to use esbuild.config.mjs"
+else
+ say "⏩ Your yarn build script is already setup. Skipping."
+end
+
+complete_step :yarn
diff --git a/lib/stimulus_reflex/broadcasters/update.rb b/lib/stimulus_reflex/broadcasters/update.rb
index 29e87bf5..530a4e4c 100644
--- a/lib/stimulus_reflex/broadcasters/update.rb
+++ b/lib/stimulus_reflex/broadcasters/update.rb
@@ -16,6 +16,7 @@ def selector
end
def html
+ return @value unless defined?(ActiveRecord)
html = @reflex.render(@key) if @key.is_a?(ActiveRecord::Base) && @value.nil?
html = @reflex.render_collection(@key) if @key.is_a?(ActiveRecord::Relation) && @value.nil?
html || @value
diff --git a/lib/stimulus_reflex/cable_readiness.rb b/lib/stimulus_reflex/cable_readiness.rb
index 162b88e1..9bb2345d 100644
--- a/lib/stimulus_reflex/cable_readiness.rb
+++ b/lib/stimulus_reflex/cable_readiness.rb
@@ -18,7 +18,7 @@ def initialize(*args, **kwargs)
#{self.class.name} includes CableReady::Broadcaster, and you need to remove it.
Reflexes have their own CableReady interface. You can just assume that it's present.
- See https://docs.stimulusreflex.com/rtfm/cableready#using-cableready-inside-a-reflex-action for more details.
+ See https://docs.stimulusreflex.com/guide/cableready#using-cableready-inside-a-reflex-action for more details.
MSG
raise TypeError.new(message.strip)
diff --git a/lib/stimulus_reflex/concern_enhancer.rb b/lib/stimulus_reflex/concern_enhancer.rb
index c6c84d7a..1e230d05 100644
--- a/lib/stimulus_reflex/concern_enhancer.rb
+++ b/lib/stimulus_reflex/concern_enhancer.rb
@@ -8,12 +8,12 @@ module ConcernEnhancer
def method_missing(name, *args)
case ancestors
when ->(a) { !(a & [StimulusReflex::Reflex]).empty? }
- if (ActiveRecord::Base.public_methods + ActionController::Base.public_methods).include? name
+ if ((defined?(ActiveRecord) ? ActiveRecord::Base.public_methods : []) + ActionController::Base.public_methods).include? name
nil
else
super
end
- when ->(a) { !(a & [ActiveRecord::Base, ActionController::Base]).empty? }
+ when ->(a) { !(a & (defined?(ActiveRecord) ? [ActiveRecord::Base, ActionController::Base] : [ActionController::Base])).empty? }
if StimulusReflex::Reflex.public_methods.include? name
nil
else
@@ -27,8 +27,8 @@ def method_missing(name, *args)
def respond_to_missing?(name, include_all = false)
case ancestors
when ->(a) { !(a & [StimulusReflex::Reflex]).empty? }
- (ActiveRecord::Base.public_methods + ActionController::Base.public_methods).include?(name) || super
- when ->(a) { !(a & [ActiveRecord::Base, ActionController::Base]).empty? }
+ ((defined?(ActiveRecord) ? ActiveRecord::Base.public_methods : []) + ActionController::Base.public_methods).include?(name) || super
+ when ->(a) { !(a & (defined?(ActiveRecord) ? [ActiveRecord::Base, ActionController::Base] : [ActionController::Base])).empty? }
StimulusReflex::Reflex.public_methods.include?(name) || super
else
super
diff --git a/lib/stimulus_reflex/dataset.rb b/lib/stimulus_reflex/dataset.rb
index 2e7963bb..e695cd3f 100644
--- a/lib/stimulus_reflex/dataset.rb
+++ b/lib/stimulus_reflex/dataset.rb
@@ -25,10 +25,20 @@ def unsigned
end
def boolean
- @boolean ||= ->(accessor) { ActiveModel::Type::Boolean.new.cast(self[accessor]) || self[accessor].blank? }
+ @boolean ||= ->(accessor) { cast_boolean(self[accessor]) || self[accessor].blank? }
end
def numeric
@numeric ||= ->(accessor) { Float(self[accessor]) }
end
+
+ private
+
+ def cast_boolean(value)
+ ((value == "") ? nil : !false_values.include?(value)) unless value.nil?
+ end
+
+ def false_values
+ @false_values ||= [false, 0, "0", "f", "F", "false", "FALSE", "off", "OFF"].to_set
+ end
end
diff --git a/lib/stimulus_reflex/importmap.rb b/lib/stimulus_reflex/importmap.rb
index 121b955a..2bf95e86 100644
--- a/lib/stimulus_reflex/importmap.rb
+++ b/lib/stimulus_reflex/importmap.rb
@@ -2,5 +2,6 @@
pin "@rails/actioncable", to: "actioncable.esm.js", preload: true
pin "@hotwired/stimulus", to: "stimulus.min.js", preload: true
+pin "morphdom", to: "https://ga.jspm.io/npm:morphdom@2.6.1/dist/morphdom.js", preload: true
pin "cable_ready", to: "cable_ready.min.js", preload: true
pin "stimulus_reflex", to: "stimulus_reflex.min.js", preload: true
diff --git a/lib/stimulus_reflex/installer.rb b/lib/stimulus_reflex/installer.rb
new file mode 100644
index 00000000..79a81e62
--- /dev/null
+++ b/lib/stimulus_reflex/installer.rb
@@ -0,0 +1,274 @@
+# frozen_string_literal: true
+
+### general utilities
+
+def fetch(step_path, file)
+ relative_path = step_path + file
+ location = template_src + relative_path
+
+ Pathname.new(location)
+end
+
+def complete_step(step)
+ create_file "tmp/stimulus_reflex_installer/#{step}", verbose: false
+end
+
+def create_or_append(path, *args, &block)
+ FileUtils.touch(path)
+ append_file(path, *args, &block)
+end
+
+def current_template
+ ENV["LOCATION"].split("/").last.gsub(".rb", "")
+end
+
+def pack_path_missing?
+ return false unless pack_path.nil?
+ halt "#{friendly_pack_path} is missing. You need a valid application pack file to proceed."
+end
+
+def halt(message)
+ say "❌ #{message}", :red
+ create_file "tmp/stimulus_reflex_installer/halt", verbose: false
+end
+
+def backup(path, delete: false)
+ if !path.exist?
+ yield
+ return
+ end
+
+ backup_path = Pathname.new("#{path}.bak")
+ old_path = path.relative_path_from(Rails.root).to_s
+ filename = path.to_path.split("/").last
+
+ if backup_path.exist?
+ if backup_path.read == path.read
+ path.delete if delete
+ yield
+ return
+ end
+ backup_path.delete
+ end
+
+ copy_file(path, backup_path, verbose: false)
+ path.delete if delete
+
+ yield
+
+ if path.read != backup_path.read
+ create_or_append(backups_path, "#{old_path}\n", verbose: false)
+ end
+ say "📦 #{old_path} backed up as #{filename}.bak"
+end
+
+def add_gem(name)
+ create_or_append(add_gem_list, "#{name}\n", verbose: false)
+ say "☑️ Added #{name} to the Gemfile"
+end
+
+def remove_gem(name)
+ create_or_append(remove_gem_list, "#{name}\n", verbose: false)
+ say "❎ Removed #{name} from Gemfile"
+end
+
+def add_package(name)
+ create_or_append(package_list, "#{name}\n", verbose: false)
+ say "☑️ Enqueued #{name} to be added to dependencies"
+end
+
+def add_dev_package(name)
+ create_or_append(dev_package_list, "#{name}\n", verbose: false)
+ say "☑️ Enqueued #{name} to be added to dev dependencies"
+end
+
+def drop_package(name)
+ create_or_append(drop_package_list, "#{name}\n", verbose: false)
+ say "❎ Enqueued #{name} to be removed from dependencies"
+end
+
+def gemfile_hash
+ Digest::MD5.hexdigest(gemfile_path.read)
+end
+
+### memoized values
+
+def sr_npm_version
+ @sr_npm_version ||= StimulusReflex::VERSION.gsub(".pre", "-pre")
+end
+
+def cr_npm_version
+ @cr_npm_version ||= CableReady::VERSION.gsub(".pre", "-pre")
+end
+
+def package_json
+ @package_json ||= Rails.root.join("package.json")
+end
+
+def entrypoint
+ @entrypoint ||= File.read("tmp/stimulus_reflex_installer/entrypoint")
+end
+
+def bundler
+ @bundler ||= File.read("tmp/stimulus_reflex_installer/bundler")
+end
+
+def network_issue_path
+ @network_issue_path ||= Rails.root.join("tmp/stimulus_reflex_installer/network_issue")
+end
+
+def config_path
+ @config_path ||= Rails.root.join(entrypoint, "config")
+end
+
+def importmap_path
+ @importmap_path ||= Rails.root.join("config/importmap.rb")
+end
+
+def friendly_importmap_path
+ @friendly_importmap_path ||= importmap_path.relative_path_from(Rails.root).to_s
+end
+
+def pack
+ @pack ||= pack_path.read
+end
+
+def friendly_pack_path
+ @friendly_pack_path ||= pack_path.relative_path_from(Rails.root).to_s
+end
+
+def pack_path
+ @pack_path ||= [
+ Rails.root.join(entrypoint, "application.js"),
+ Rails.root.join(entrypoint, "packs/application.js"),
+ Rails.root.join(entrypoint, "entrypoints/application.js")
+ ].find(&:exist?)
+end
+
+def package_list
+ @package_list ||= Rails.root.join("tmp/stimulus_reflex_installer/npm_package_list")
+end
+
+def dev_package_list
+ @dev_package_list ||= Rails.root.join("tmp/stimulus_reflex_installer/npm_dev_package_list")
+end
+
+def drop_package_list
+ @drop_package_list ||= Rails.root.join("tmp/stimulus_reflex_installer/drop_npm_package_list")
+end
+
+def template_src
+ @template_src ||= File.read("tmp/stimulus_reflex_installer/template_src")
+end
+
+def controllers_path
+ @controllers_path ||= Rails.root.join(entrypoint, "controllers")
+end
+
+def gemfile_path
+ @gemfile_path ||= Rails.root.join("Gemfile")
+end
+
+def gemfile
+ @gemfile ||= gemfile_path.read
+end
+
+def prefix
+ # standard:disable Style/RedundantStringEscape
+ @prefix ||= {
+ "vite" => "..\/",
+ "webpacker" => "",
+ "shakapacker" => "",
+ "importmap" => "",
+ "esbuild" => ".\/"
+ }[bundler]
+ # standard:enable Style/RedundantStringEscape
+end
+
+def application_record_path
+ @application_record_path ||= Rails.root.join("app/models/application_record.rb")
+end
+
+def action_cable_initializer_path
+ @action_cable_initializer_path ||= Rails.root.join("config/initializers/action_cable.rb")
+end
+
+def action_cable_initializer_working_path
+ @action_cable_initializer_working_path ||= Rails.root.join(working, "action_cable.rb")
+end
+
+def development_path
+ @development_path ||= Rails.root.join("config/environments/development.rb")
+end
+
+def development_working_path
+ @development_working_path ||= Rails.root.join(working, "development.rb")
+end
+
+def backups_path
+ @backups_path ||= Rails.root.join("tmp/stimulus_reflex_installer/backups")
+end
+
+def add_gem_list
+ @add_gem_list ||= Rails.root.join("tmp/stimulus_reflex_installer/add_gem_list")
+end
+
+def remove_gem_list
+ @remove_gem_list ||= Rails.root.join("tmp/stimulus_reflex_installer/remove_gem_list")
+end
+
+def options_path
+ @options_path ||= Rails.root.join("tmp/stimulus_reflex_installer/options")
+end
+
+def options
+ @options ||= YAML.safe_load(File.read(options_path))
+end
+
+def working
+ @working ||= Rails.root.join("tmp/stimulus_reflex_installer/working")
+end
+
+### support for development step
+
+def write_redis_recommendation(development_working, lines, index, gemfile)
+ # provide a recommendation for using redis-session-store, including commented source code
+ if !lines.index { |line| line.include?("StimulusReflex does not support :cookie_store") }
+ lines.insert index + 1, <(*a) { Rails.logger.error("Redis down! \#{a.inspect}") },
+ # redis: {
+ # expire_after: 120.minutes,
+ # key_prefix: "session:",
+ # url: ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" }
+ # }
+RUBY
+ development_working.write lines.join
+ # add redis-session-store to Gemfile, but comment it out
+ if !gemfile.match?(/gem ['"]redis-session-store['"]/)
+ append_file(gemfile_path, verbose: false) do
+ <<~RUBY
+
+ # StimulusReflex recommends using Redis for session storage
+ # gem "redis-session-store", "0.11.5"
+ RUBY
+ end
+ say "💡 Added redis-session-store 0.11.5 to the Gemfile, commented out"
+ end
+ end
+end
+
+def find_index(lines)
+ # accomodate devs who modify their development.rb file structure
+ if (index = lines.index { |line| line =~ /caching-dev/ })
+ index += 3
+ else
+ index = lines.index { |line| line =~ /^Rails.application.configure do/ } + 1
+ end
+ index
+end
diff --git a/lib/stimulus_reflex/reflex.rb b/lib/stimulus_reflex/reflex.rb
index fd92740c..259617f4 100644
--- a/lib/stimulus_reflex/reflex.rb
+++ b/lib/stimulus_reflex/reflex.rb
@@ -47,7 +47,7 @@ def initialize(channel, url: nil, element: nil, selectors: [], method_name: nil,
# TODO: remove this for v4
def reflex_id
- puts "Deprecation warning: reflex_id will be removed in v4. Use id instead!" if Rails.env.development?
+ warn "Deprecation warning: reflex_id will be removed in v4. Use id instead!" if Rails.env.development?
id
end
# END TODO: remove
diff --git a/lib/stimulus_reflex/utils/sanity_checker.rb b/lib/stimulus_reflex/utils/sanity_checker.rb
index 2c0ccc85..d88ac495 100644
--- a/lib/stimulus_reflex/utils/sanity_checker.rb
+++ b/lib/stimulus_reflex/utils/sanity_checker.rb
@@ -53,7 +53,7 @@ def check_caching_enabled
def check_default_url_config
return if StimulusReflex.config.on_missing_default_urls == :ignore
- if default_url_config_set? == false
+ if default_url_config_missing?
puts <<~WARN
👉 StimulusReflex strongly suggests that you set default_url_options in your environment files. Otherwise, ActionController #{"and ActionMailer " if defined?(ActionMailer)}will default to example.com when rendering route helpers.
@@ -75,18 +75,18 @@ def using_null_store?
Rails.application.config.cache_store == :null_store
end
+ def initializer_missing?
+ File.exist?(Rails.root.join("config", "initializers", "stimulus_reflex.rb")) == false
+ end
+
def default_url_config_set?
if defined?(ActionMailer)
- Rails.application.config.action_controller.default_url_options.blank? && Rails.application.config.action_mailer.default_url_options.blank?
+ Rails.application.config.action_controller.default_url_options.blank? || Rails.application.config.action_mailer.default_url_options.blank?
else
Rails.application.config.action_controller.default_url_options.blank?
end
end
- def initializer_missing?
- File.exist?(Rails.root.join("config", "initializers", "stimulus_reflex.rb")) == false
- end
-
def warn_and_exit(text)
puts
puts "Heads up! 🔥"
@@ -106,15 +106,7 @@ def warn_and_exit(text)
end
INFO
- if initializer_missing?
- puts <<~INFO
- You can create a StimulusReflex initializer with the command:
-
- bundle exec rails generate stimulus_reflex:initializer
-
- INFO
- end
- exit false if Rails.env.test? == false
+ exit false unless Rails.env.test?
end
end
end
diff --git a/lib/tasks/stimulus_reflex/install.rake b/lib/tasks/stimulus_reflex/install.rake
deleted file mode 100644
index 25afd1d5..00000000
--- a/lib/tasks/stimulus_reflex/install.rake
+++ /dev/null
@@ -1,116 +0,0 @@
-# frozen_string_literal: true
-
-require "fileutils"
-require "stimulus_reflex/version"
-
-namespace :stimulus_reflex do
- desc "✨ Install StimulusReflex in this application"
- task install: :environment do
- system "rails dev:cache" unless Rails.root.join("tmp", "caching-dev.txt").exist?
- gem_version = StimulusReflex::VERSION.gsub(".pre", "-pre")
- system "yarn add stimulus_reflex@#{gem_version}"
- system "bundle exec rails webpacker:install:stimulus"
- main_folder = defined?(Webpacker) ? Webpacker.config.source_path.to_s.gsub("#{Rails.root}/", "") : "app/javascript"
-
- FileUtils.mkdir_p Rails.root.join("#{main_folder}/controllers"), verbose: true
- FileUtils.mkdir_p Rails.root.join("app/reflexes"), verbose: true
-
- filepath = [
- "#{main_folder}/controllers/index.js",
- "#{main_folder}/controllers/index.ts",
- "#{main_folder}/packs/application.js",
- "#{main_folder}/packs/application.ts"
- ]
- .select { |path| File.exist?(path) }
- .map { |path| Rails.root.join(path) }
- .first
-
- puts "✨ Updating #{filepath}"
- lines = File.readlines(filepath)
-
- unless lines.find { |line| line.start_with?("import StimulusReflex") }
- matches = lines.select { |line| line =~ /\A(require|import)/ }
- lines.insert lines.index(matches.last).to_i + 1, "import StimulusReflex from 'stimulus_reflex'\n"
- end
-
- unless lines.find { |line| line.start_with?("import consumer") }
- matches = lines.select { |line| line =~ /\A(require|import)/ }
- lines.insert lines.index(matches.last).to_i + 1, "import consumer from '../channels/consumer'\n"
- end
-
- unless lines.find { |line| line.start_with?("import controller") }
- matches = lines.select { |line| line =~ /\A(require|import)/ }
- lines.insert lines.index(matches.last).to_i + 1, "import controller from '../controllers/application_controller'\n"
- end
-
- initialize_line = lines.find { |line| line.start_with?("StimulusReflex.initialize") }
- lines << "application.consumer = consumer\n"
- lines << "StimulusReflex.initialize(application, { controller, isolate: true })\n" unless initialize_line
- lines << "StimulusReflex.debug = process.env.RAILS_ENV === 'development'\n" unless initialize_line
- File.write(filepath, lines.join)
-
- puts
- puts "✨ Updating config/environments/development.rb"
- filepath = Rails.root.join("config/environments/development.rb")
- lines = File.readlines(filepath)
- unless lines.find { |line| line.include?("config.session_store") }
- matches = lines.select { |line| line =~ /\A(Rails.application.configure do)/ }
- lines.insert lines.index(matches.last).to_i + 1, " config.session_store :cache_store\n\n"
- puts
- puts "✨ Using :cache_store for session storage. We recommend switching to Redis for cache and session storage."
- puts
- puts "https://docs.stimulusreflex.com/appendices/deployment#use-redis-as-your-cache-store"
- File.write(filepath, lines.join)
- end
-
- if defined?(ActionMailer)
- lines = File.readlines(filepath)
- unless lines.find { |line| line.include?("config.action_mailer.default_url_options") }
- matches = lines.select { |line| line =~ /\A(Rails.application.configure do)/ }
- lines.insert lines.index(matches.last).to_i + 1, " config.action_mailer.default_url_options = {host: \"localhost\", port: 3000}\n\n"
- File.write(filepath, lines.join)
- end
- end
-
- lines = File.readlines(filepath)
- unless lines.find { |line| line.include?("config.action_controller.default_url_options") }
- matches = lines.select { |line| line =~ /\A(Rails.application.configure do)/ }
- lines.insert lines.index(matches.last).to_i + 1, " config.action_controller.default_url_options = {host: \"localhost\", port: 3000}\n"
- File.write(filepath, lines.join)
- end
-
- puts
- puts "✨ Updating config/cable.yml to use Redis in development"
- filepath = Rails.root.join("config/cable.yml")
- lines = File.readlines(filepath)
- if lines[1].include?("adapter: async")
- lines.delete_at 1
- lines.insert 1, " adapter: redis\n"
- lines.insert 2, " url: <%= ENV.fetch(\"REDIS_URL\") { \"redis://localhost:6379/1\" } %>\n"
- lines.insert 3, " channel_prefix: " + File.basename(Rails.root.to_s).tr("\\", "").tr("-. ", "_").underscore + "_development\n"
- File.write(filepath, lines.join)
- end
-
- puts
- puts "✨ Generating default StimulusReflex and CableReady configuration files"
- puts
- system "bundle exec rails generate stimulus_reflex:initializer"
- system "bundle exec rails generate cable_ready:initializer"
- system "bundle exec rails generate cable_ready:helpers"
-
- puts
- puts "✨ Generating ApplicationReflex class and Stimulus controllers, plus an example Reflex class and controller"
- puts
- system "bundle exec rails generate stimulus_reflex example"
-
- puts
- puts "🎉 StimulusReflex and CableReady have been successfully installed! 🎉"
- puts
- puts "https://docs.stimulusreflex.com/hello-world/quickstart"
- puts
- puts "😊 The fastest way to get support is to say hello on Discord:"
- puts
- puts "https://discord.gg/stimulus-reflex"
- puts
- end
-end
diff --git a/lib/tasks/stimulus_reflex/stimulus_reflex.rake b/lib/tasks/stimulus_reflex/stimulus_reflex.rake
new file mode 100644
index 00000000..18806c8a
--- /dev/null
+++ b/lib/tasks/stimulus_reflex/stimulus_reflex.rake
@@ -0,0 +1,252 @@
+# frozen_string_literal: true
+
+include Rails.application.routes.url_helpers
+
+SR_STEPS = {
+ "action_cable" => "Action Cable",
+ "webpacker" => "Install StimulusReflex using Webpacker",
+ "shakapacker" => "Install StimulusReflex using Shakapacker",
+ "npm_packages" => "StimulusReflex and CableReady and related npm packages",
+ "reflexes" => "Create app/reflexes and related files",
+ "importmap" => "Install StimulusReflex using importmaps",
+ "esbuild" => "Install StimulusReflex using esbuild",
+ "config" => "Client initialization",
+ "initializers" => "StimulusReflex and CableReady initializers",
+ "example" => "Create an Example Reflex",
+ "development" => "development environment configuration",
+ "spring" => "Disable spring gem. Spring has been removed from Rails 7",
+ "mrujs" => "Swap out Rails UJS for mrujs",
+ "broadcaster" => "Make CableReady available to channels, controllers, jobs and models",
+ "updatable" => "Include CableReady::Updatable in Active Record model classes",
+ "yarn" => "Resolve npm dependency changes",
+ "bundle" => "Resolve gem dependency changes and install configuration changes",
+ "vite" => "Install StimulusReflex using Vite",
+ "compression" => "Compress WebSocket traffic with gzip"
+}
+
+SR_BUNDLERS = {
+ "webpacker" => ["npm_packages", "webpacker", "config", "action_cable", "reflexes", "development", "initializers", "broadcaster", "updatable", "example", "spring", "yarn", "bundle"],
+ "esbuild" => ["npm_packages", "esbuild", "config", "action_cable", "reflexes", "development", "initializers", "broadcaster", "updatable", "example", "spring", "yarn", "bundle"],
+ "vite" => ["npm_packages", "vite", "config", "action_cable", "reflexes", "development", "initializers", "broadcaster", "updatable", "example", "spring", "yarn", "bundle"],
+ "shakapacker" => ["npm_packages", "shakapacker", "config", "action_cable", "reflexes", "development", "initializers", "broadcaster", "updatable", "example", "spring", "yarn", "bundle"],
+ "importmap" => ["config", "action_cable", "importmap", "reflexes", "development", "initializers", "broadcaster", "updatable", "example", "spring", "bundle"]
+}
+
+def run_install_template(template, force: false, trace: false)
+ puts "--- [#{template}] ----"
+
+ if Rails.root.join("tmp/stimulus_reflex_installer/halt").exist?
+ FileUtils.rm(Rails.root.join("tmp/stimulus_reflex_installer/halt"))
+ puts "StimulusReflex installation halted. Please fix the issues above and try again."
+ exit
+ end
+ if Rails.root.join("tmp/stimulus_reflex_installer/#{template}").exist? && !force
+ puts "👍 #{SR_STEPS[template]}"
+ return
+ end
+
+ system "#{RbConfig.ruby} ./bin/rails app:template LOCATION=#{File.expand_path("../../install/#{template}.rb", __dir__)} SKIP_SANITY_CHECK=true #{"--trace" if trace}"
+
+ puts
+end
+
+namespace :stimulus_reflex do
+ desc "✨ Install StimulusReflex and CableReady ✨"
+ task :install do
+ FileUtils.mkdir_p(Rails.root.join("tmp/stimulus_reflex_installer/templates"))
+ FileUtils.mkdir_p(Rails.root.join("tmp/stimulus_reflex_installer/working"))
+ install_complete = Rails.root.join("tmp/stimulus_reflex_installer/complete")
+
+ bundler = nil
+ options = {}
+
+ ARGV.each do |arg|
+ # make sure we have a valid build tool specified, or proceed to automatic detection
+ if ["webpacker", "esbuild", "vite", "shakapacker", "importmap"].include?(arg)
+ bundler = arg
+ else
+ kv = arg.split("=")
+ if kv.length == 2
+ kv[1] = if kv[1] == "true"
+ true
+ else
+ (kv[1] == "false") ? false : kv[1]
+ end
+ options[kv[0]] = kv[1]
+ end
+ end
+ end
+
+ options_path = Rails.root.join("tmp/stimulus_reflex_installer/options")
+ options_path.write(options.to_yaml)
+
+ if install_complete.exist?
+ puts "✨ \e[38;5;220mStimulusReflex\e[0m and \e[38;5;220mCableReady\e[0m are already installed ✨"
+ puts
+ puts "To restart the installation process, run: \e[38;5;231mrails stimulus_reflex:install:restart\e[0m"
+ puts
+ puts "To get started, check out \e[4;97mhttps://docs.stimulusreflex.com/hello-world/quickstart\e[0m"
+ puts "or get help on Discord: \e[4;97mhttps://discord.gg/stimulus-reflex\e[0m. \e[38;5;196mWe are here for you.\e[0m 💙"
+ puts
+ exit
+ end
+
+ # if there is an installation in progress, continue where we left off
+ cached_entrypoint = Rails.root.join("tmp/stimulus_reflex_installer/entrypoint")
+ if cached_entrypoint.exist?
+ entrypoint = File.read(cached_entrypoint)
+ puts "✨ Resuming \e[38;5;220mStimulusReflex\e[0m and \e[38;5;220mCableReady\e[0m installation ✨"
+ puts
+ puts "If you have any setup issues, please consult \e[4;97mhttps://docs.stimulusreflex.com/hello-world/setup\e[0m"
+ puts "or get help on Discord: \e[4;97mhttps://discord.gg/stimulus-reflex\e[0m. \e[38;5;196mWe are here for you.\e[0m 💙"
+ puts
+ puts "Resuming installation into \e[1m#{entrypoint}\e[22m"
+ puts "Run \e[1;94mrails stimulus_reflex:install:restart\e[0m to restart the installation process"
+ puts
+ else
+ puts "✨ Installing \e[38;5;220mStimulusReflex\e[0m and \e[38;5;220mCableReady\e[0m ✨"
+ puts
+ puts "If you have any setup issues, please consult \e[4;97mhttps://docs.stimulusreflex.com/hello-world/setup\e[0m"
+ puts "or get help on Discord: \e[4;97mhttps://discord.gg/stimulus-reflex\e[0m. \e[38;5;196mWe are here for you.\e[0m 💙"
+ if Rails.root.join(".git").exist?
+ puts
+ puts "We recommend running \e[1;94mgit commit\e[0m before proceeding. A diff will be generated at the end."
+ end
+
+ if options.key? "entrypoint"
+ entrypoint = options["entrypoint"]
+ else
+ entrypoint = [
+ "app/javascript",
+ "app/frontend"
+ ].find { |path| File.exist?(Rails.root.join(path)) } || "app/javascript"
+
+ puts
+ puts "Where do JavaScript files live in your app? Our best guess is: \e[1m#{entrypoint}\e[22m 🤔"
+ puts "Press enter to accept this, or type a different path."
+ print "> "
+ input = $stdin.gets.chomp
+ entrypoint = input unless input.blank?
+ end
+ File.write(cached_entrypoint, entrypoint)
+ end
+
+ # verify their bundler before starting, unless they explicitly specified on CLI
+ if !bundler
+ # auto-detect build tool based on existing packages and configuration
+ if Rails.root.join("config/importmap.rb").exist?
+ bundler = "importmap"
+ elsif Rails.root.join("package.json").exist?
+ package_json = File.read(Rails.root.join("package.json"))
+ bundler = "webpacker" if package_json.include?('"@rails/webpacker":')
+ bundler = "esbuild" if package_json.include?('"esbuild":')
+ bundler = "vite" if package_json.include?('"vite":')
+ bundler = "shakapacker" if package_json.include?('"shakapacker":')
+ if !bundler
+ puts "❌ You must be using a node-based bundler such as esbuild, webpacker, vite or shakapacker (package.json) or importmap (config/importmap.rb) to use StimulusReflex."
+ exit
+ end
+ else
+ puts "❌ You must be using a node-based bundler such as esbuild, webpacker, vite or shakapacker (package.json) or importmap (config/importmap.rb) to use StimulusReflex."
+ exit
+ end
+
+ puts
+ puts "It looks like you're using \e[1m#{bundler}\e[22m as your bundler. Is that correct? (Y/n)"
+ print "> "
+ input = $stdin.gets.chomp
+ if input.downcase == "n"
+ puts
+ puts "StimulusReflex installation supports: esbuild, webpacker, vite, shakapacker and importmap."
+ puts "Please run \e[1;94mrails stimulus_reflex:install [bundler]\e[0m to install StimulusReflex and CableReady."
+ exit
+ end
+ end
+
+ File.write("tmp/stimulus_reflex_installer/bundler", bundler)
+ FileUtils.touch("tmp/stimulus_reflex_installer/backups")
+ File.write("tmp/stimulus_reflex_installer/template_src", File.expand_path("../../generators/stimulus_reflex/templates/", __dir__))
+
+ `bin/spring stop` if defined?(Spring)
+
+ # do the things
+ SR_BUNDLERS[bundler].each do |template|
+ run_install_template(template, trace: !!options["trace"])
+ end
+
+ puts
+ puts "🎉 \e[1;92mStimulusReflex and CableReady have been successfully installed!\e[22m 🎉"
+ puts
+ puts "👉 \e[4;97mhttps://docs.stimulusreflex.com/hello-world/quickstart\e[0m"
+ puts
+ puts "Join over 2000 StimulusReflex developers on Discord: \e[4;97mhttps://discord.gg/stimulus-reflex\e[0m"
+ puts
+
+ backups = File.readlines("tmp/stimulus_reflex_installer/backups").map(&:chomp)
+
+ if backups.any?
+ puts "🙆 The following files were modified during installation:"
+ puts
+ backups.each { |backup| puts " #{backup}" }
+ puts
+ puts "Each of these files has been backed up with a .bak extension. Please review the changes carefully."
+ puts "If you're happy with the changes, you can delete the .bak files."
+ puts
+ end
+
+ if Rails.root.join(".git").exist?
+ system "git diff > tmp/stimulus_reflex_installer.diff"
+ puts "🏮 A diff of all changes has been saved to \e[1mtmp/stimulus_reflex_installer.diff\e[22m"
+ puts
+ end
+
+ if Rails.root.join("app/reflexes/example_reflex.rb").exist?
+ launch = Rails.root.join("bin/dev").exist? ? "bin/dev" : "rails s"
+ puts "🚀 Launch \e[1;94m#{launch}\e[0m to access the example at ⚡ \e[4;97mhttp://localhost:3000/example\e[0m ⚡"
+ puts "Once you're finished with the example, you can remove it with \e[1;94mrails destroy stimulus_reflex example\e[0m"
+ puts
+ end
+
+ FileUtils.touch(install_complete)
+ `pkill -f spring` if Rails.root.join("tmp/stimulus_reflex_installer/kill_spring").exist?
+ exit
+ end
+
+ namespace :install do
+ desc "Restart StimulusReflex and CableReady installation"
+ task :restart do
+ FileUtils.rm_rf Rails.root.join("tmp/stimulus_reflex_installer")
+ system "rails stimulus_reflex:install #{ARGV.join(" ")}"
+ exit
+ end
+
+ desc <<~DESC
+ Run specific StimulusReflex install steps
+
+ #{SR_STEPS.sort.map { |step, description| "#{step.ljust(20)} #{description}" }.join("\n")}
+ DESC
+ task :step do
+ def warning(step = nil, force_exit = true)
+ return if step.to_s.include?("=")
+ if step
+ puts "⚠️ #{step} is not a valid step. Valid steps are: #{SR_STEPS.keys.join(", ")}"
+ else
+ puts "❌ You must specify a step to re-run. Valid steps are: #{SR_STEPS.keys.join(", ")}"
+ puts "Example: \e[1;94mrails stimulus_reflex:install:step initializers\e[0m"
+ end
+ exit if force_exit
+ end
+
+ warning if ARGV.empty?
+
+ ARGV.each do |step|
+ SR_STEPS.include?(step) ? run_install_template(step, force: true) : warning(step, false)
+ end
+
+ run_install_template(:bundle, force: true)
+ run_install_template(:yarn, force: true)
+
+ exit
+ end
+ end
+end
diff --git a/test/generators/stimulus_reflex_generator_test.rb b/test/generators/stimulus_reflex_generator_test.rb
index b8e010c2..70f990fe 100644
--- a/test/generators/stimulus_reflex_generator_test.rb
+++ b/test/generators/stimulus_reflex_generator_test.rb
@@ -11,36 +11,30 @@ class StimulusReflexGeneratorTest < Rails::Generators::TestCase
test "creates singular named controller and reflex files" do
run_generator %w[demo]
- assert_file "app/javascript/controllers/application_controller.js"
- assert_file "app/javascript/controllers/demo_controller.js", /Demo/
- assert_file "app/reflexes/application_reflex.rb"
- assert_file "app/reflexes/demo_reflex.rb", /DemoReflex/
+ assert_file "../../tmp/app/javascript/controllers/demo_controller.js"
+ assert_file "../../tmp/app/reflexes/demo_reflex.rb", /DemoReflex/
end
test "creates plural named controller and reflex files" do
run_generator %w[posts]
- assert_file "app/javascript/controllers/application_controller.js"
- assert_file "app/javascript/controllers/posts_controller.js", /Posts/
- assert_file "app/reflexes/application_reflex.rb"
- assert_file "app/reflexes/posts_reflex.rb", /PostsReflex/
+ assert_file "../../tmp/app/javascript/controllers/posts_controller.js"
+ assert_file "../../tmp/app/reflexes/posts_reflex.rb", /PostsReflex/
end
test "skips stimulus controller and reflex if option provided" do
- run_generator %w[users --skip-stimulus --skip-reflex --skip-app-controller --skip-app-reflex]
- assert_no_file "app/javascript/controllers/application_controller.js"
- assert_no_file "app/javascript/controllers/users_controller.js"
- assert_no_file "app/reflexes/application_reflex.rb"
- assert_no_file "app/reflexes/users_reflex.rb"
+ run_generator %w[users --skip-stimulus --skip-reflex]
+ assert_no_file "../../tmp/app/javascript/controllers/users_controller.js"
+ assert_no_file "../../tmp/app/reflexes/users_reflex.rb"
end
test "creates reflex with given reflex actions" do
run_generator %w[User update do_stuff DoMoreStuff]
- assert_file "app/reflexes/user_reflex.rb" do |reflex|
+ assert_file "../../tmp/app/reflexes/user_reflex.rb" do |reflex|
assert_instance_method :update, reflex
assert_instance_method :do_stuff, reflex
assert_instance_method :do_more_stuff, reflex
end
- assert_file "app/javascript/controllers/user_controller.js" do |controller|
+ assert_file "../../tmp/app/javascript/controllers/user_controller.js" do |controller|
assert_match(/beforeUpdate/, controller)
assert_match(/updateSuccess/, controller)
assert_match(/updateError/, controller)