engine_map 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3b3873a0c9fbc083b966ba188397878d2b264f0722da9fd3a4df38a418d107ec
4
+ data.tar.gz: b835262fef07f989f20e3f548b778f34a6a302e688da74e95c1c85ff28951d8d
5
+ SHA512:
6
+ metadata.gz: 2340384f5cae24fcb74ce7bf4a101a5b285263168d9d4504cdc56a52d2668d3a923a7b1580f21fa8aaa6d1ea5e862ca22efcf3d3a031d894a0c4b15d31d1a393
7
+ data.tar.gz: 02def4daf9ce5e73949026998940f58875bc79be408fa60aedc3230d561f2a5c4a71d46c6449044af82ba4009814f18c3ababbcccc105425f39034fe88b7ea5f
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Initial development.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright Baden
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,83 @@
1
+ # EngineMap
2
+
3
+ Asset helpers and an install generator for Rails engines (Rails 8, importmap, Stimulus), on Propshaft or Sprockets.
4
+
5
+ EngineMap gives you:
6
+
7
+ - `engine_stylesheet_link_tags "my_engine", :all` links every CSS file in the engine's stylesheet folder, with digests.
8
+ - Importmap modes: merge the engine's pins into the host's map, use an independent map, or both.
9
+ - Namespaced Stimulus controllers that can't collide with the host's.
10
+ - An install generator that injects into existing files, backs up anything it replaces, and is safe to re-run.
11
+
12
+ No database, no routes, no models.
13
+
14
+ ## Install
15
+
16
+ Add the gem to the engine's (or host app's) Gemfile, then run the generator:
17
+
18
+ ```ruby
19
+ gem "engine_map"
20
+ ```
21
+
22
+ ```sh
23
+ bundle install
24
+ bin/rails g engine_map:install
25
+ ```
26
+
27
+ Run it from inside an engine and that engine is pre-selected. Otherwise you get a numbered list of the engines your app has loaded. The host app is listed too.
28
+
29
+ | Option | Values | Default |
30
+ |---|---|---|
31
+ | `--engine NAME` | skip the prompt | |
32
+ | `--importmap` | `merge`, `own`, `both` | `merge` |
33
+ | `--stimulus` | `host`, `own`, `none` | `host` |
34
+ | `--all` | also list engines from installed gems | off |
35
+
36
+ ## What the generator changes
37
+
38
+ - `lib/<engine>/engine.rb`: adds `require "engine_map"` and `EngineMap.configure(self)`.
39
+ - `<engine>.gemspec`: adds `spec.add_dependency "engine_map"`.
40
+ - `config/importmap.rb`, `app/javascript/<engine>/...` and a starter stylesheet are created if missing.
41
+ - The engine's layout: its own stylesheet tag is replaced by `engine_stylesheet_link_tags`, and importmap tags are added.
42
+
43
+ When a line is replaced, the file gets a `.backup` next to it (never overwritten: `.backup.1`, ...). If a file or anchor can't be found, the generator prints the exact lines to add by hand. Running it twice changes nothing and says so.
44
+
45
+ ## Importmap modes
46
+
47
+ A page can only render one import map, so each layout picks one.
48
+
49
+ **merge (default).** The engine's pins join the host's map. Use the normal `javascript_importmap_tags` plus `javascript_import_module_tag "my_engine/application"`.
50
+
51
+ **own.** The engine has an independent map, with a helper named after the engine:
52
+
53
+ ```erb
54
+ <%= my_engine_javascript_importmap_tags %>
55
+ ```
56
+
57
+ **both** enables both and generates `app/views/layouts/my_engine/standalone.html.erb`, an independent layout the engine can opt into.
58
+
59
+ ## Stimulus
60
+
61
+ Controllers live in `app/javascript/my_engine/controllers/` and are pinned as `controllers/my_engine/...`, so the host's `eagerLoadControllersFrom("controllers", ...)` finds them. Identifiers are namespaced:
62
+
63
+ ```erb
64
+ <div data-controller="my-engine--example"></div>
65
+ ```
66
+
67
+ With `--stimulus own`, the engine also gets `my_engine/standalone.js`, which starts its own Stimulus application. Only the engine's own importmap helper loads it, never a merged layout.
68
+
69
+ ## Sprockets
70
+
71
+ The engine registers its own CSS and JS for precompile, so the host's `manifest.js` needs no engine-specific lines. A Sprockets host must still link its own `app/javascript` in `manifest.js`. When the target is a host app, the generator checks this and adds the lines if missing.
72
+
73
+ ## Development
74
+
75
+ ```sh
76
+ bin/test # Propshaft
77
+ BUNDLE_GEMFILE=gemfiles/sprockets.gemfile bin/test # Sprockets
78
+ bundle exec rake test:pipelines # both
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT.
data/Rakefile ADDED
@@ -0,0 +1,13 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
4
+
5
+ namespace :test do
6
+ desc "Run the suite under Propshaft and Sprockets"
7
+ task :pipelines do
8
+ ["Gemfile", "gemfiles/sprockets.gemfile"].each do |gemfile|
9
+ puts "\n== #{gemfile} =="
10
+ system({ "BUNDLE_GEMFILE" => gemfile }, "bin/test") || abort("failed under #{gemfile}")
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,51 @@
1
+ module EngineMap
2
+ module Generators
3
+ Target = Struct.new(:name, :root, :namespace, :host, keyword_init: true) do
4
+ def label = host ? "#{name} (host app)" : name
5
+ end
6
+
7
+ class EnginePicker
8
+ def initialize(engines, app: nil, gem_paths: Gem.path, cwd: Dir.pwd, all: false)
9
+ @engines = engines
10
+ @app = app
11
+ @gem_paths = gem_paths.map { |p| File.expand_path(p) }
12
+ @cwd = File.expand_path(cwd)
13
+ @all = all
14
+ end
15
+
16
+ def targets
17
+ @targets ||= begin
18
+ list = @engines.reject { |e| !@all && installed_gem?(e.root) }.map { |e| build(e) }
19
+ list.unshift(build(@app, host: true)) if @app
20
+ list.uniq { |t| t.root.to_s }
21
+ end
22
+ end
23
+
24
+ # The target containing the current directory, if any.
25
+ def default
26
+ targets.select { |t| inside?(@cwd, t.root) }.max_by { |t| t.root.to_s.length }
27
+ end
28
+
29
+ def find(name)
30
+ targets.find { |t| t.name == name.to_s || t.namespace == name.to_s }
31
+ end
32
+
33
+ private
34
+
35
+ def build(engine, host: false)
36
+ klass = engine.class
37
+ name = host ? klass.module_parent_name.to_s.underscore : EngineMap.name_for(klass)
38
+ namespace = klass.railtie_namespace&.name || name.camelize
39
+ Target.new(name: name, root: Pathname.new(engine.root.to_s), namespace: namespace, host: host)
40
+ end
41
+
42
+ def installed_gem?(root)
43
+ @gem_paths.any? { |p| root.to_s.start_with?("#{p}/") }
44
+ end
45
+
46
+ def inside?(dir, root)
47
+ dir == root.to_s || dir.start_with?("#{root}/")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,90 @@
1
+ require "fileutils"
2
+
3
+ module EngineMap
4
+ module Generators
5
+ # Failsafe file edits for generators. Order of preference:
6
+ # 1. safe_inject - add a line next to an anchor (no backup needed)
7
+ # 2. safe_replace - change existing text (writes a .backup first)
8
+ # 3. safe_rewrite - replace a whole file (writes a .backup first)
9
+ # Every method returns true if it changed something, false if not.
10
+ module SafeFiles
11
+ def safe_inject(path, text, after: nil, before: nil)
12
+ file = absolute_path(path)
13
+ return manual(path, text, "file not found", :red) unless File.exist?(file)
14
+
15
+ content = File.read(file)
16
+ return skipped(path) if content.include?(text.strip)
17
+
18
+ anchor = after || before
19
+ found = anchor.is_a?(Regexp) ? content.match?(anchor) : content.include?(anchor)
20
+ return manual(path, text, "anchor not found", :yellow) unless found
21
+
22
+ if after
23
+ inject_into_file path, "\n#{text.chomp}", after: after
24
+ else
25
+ inject_into_file path, "#{text.chomp}\n", before: before
26
+ end
27
+ true
28
+ end
29
+
30
+ def safe_replace(path, pattern, replacement)
31
+ file = absolute_path(path)
32
+ return manual(path, replacement, "file not found", :red) unless File.exist?(file)
33
+
34
+ content = File.read(file)
35
+ return skipped(path) if content.include?(replacement)
36
+ return manual(path, replacement, "pattern not found", :yellow) unless content.match?(pattern)
37
+
38
+ backup_file(path)
39
+ gsub_file path, pattern, replacement
40
+ true
41
+ end
42
+
43
+ def safe_rewrite(source, destination)
44
+ backup_file(destination) if File.exist?(absolute_path(destination))
45
+ template source, destination, force: true
46
+ true
47
+ end
48
+
49
+ private
50
+
51
+ def absolute_path(path)
52
+ File.expand_path(path, destination_root)
53
+ end
54
+
55
+ # Never overwrites an existing backup: file.backup, file.backup.1, ...
56
+ def backup_file(path)
57
+ file = absolute_path(path)
58
+ target = "#{file}.backup"
59
+ target = "#{file}.backup.#{target.split('.').last.to_i + 1}" while File.exist?(target)
60
+ FileUtils.cp(file, target)
61
+ say_status :backup, target.delete_prefix("#{destination_root}/"), :blue
62
+ end
63
+
64
+ # One "identical" line per file per run, however many steps inspect it.
65
+ def skipped(path)
66
+ @reported_skips ||= []
67
+ unless @reported_skips.include?(path)
68
+ @reported_skips << path
69
+ say_status :identical, "#{path} (already up to date)", :yellow
70
+ end
71
+ false
72
+ end
73
+
74
+ # For steps that can't even be attempted (no file to edit).
75
+ def needs_manual(message)
76
+ @manual_steps = (@manual_steps || 0) + 1
77
+ say_status :manual, message, :yellow
78
+ false
79
+ end
80
+
81
+ def manual(path, text, reason, color)
82
+ @manual_steps = (@manual_steps || 0) + 1
83
+ say_status :manual, "#{path} (#{reason})", color
84
+ say " Add this by hand:", :bold
85
+ text.each_line { |line| say " #{line.chomp}", :cyan }
86
+ false
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,49 @@
1
+ module EngineMap
2
+ module Helpers
3
+ def self.define_importmap_helper(name)
4
+ define_method("#{name.tr('-', '_')}_javascript_importmap_tags") do |entry_point = nil|
5
+ engine_javascript_importmap_tags(name, entry_point)
6
+ end
7
+ end
8
+
9
+ # engine_javascript_importmap_tags "admin" # entry "admin/application"
10
+ # engine_javascript_importmap_tags "admin", "admin/other"
11
+ def engine_javascript_importmap_tags(engine, entry_point = nil)
12
+ javascript_importmap_tags(entry_point || EngineMap.default_entry(engine),
13
+ importmap: EngineMap.importmap_for(engine))
14
+ end
15
+
16
+ # engine_stylesheet_link_tags "my_engine", :all
17
+ # engine_stylesheet_link_tags "my_engine", "buttons", "forms"
18
+ def engine_stylesheet_link_tags(prefix, *sources, **options)
19
+ names = sources.include?(:all) ? engine_stylesheet_paths(prefix) : sources.map { |s| "#{prefix}/#{s}" }
20
+ stylesheet_link_tag(*names, **options)
21
+ end
22
+
23
+ private
24
+
25
+ def engine_stylesheet_paths(prefix)
26
+ if defined?(Propshaft)
27
+ Rails.application.assets.load_path.assets
28
+ .map { |asset| asset.logical_path.to_s }
29
+ .select { |path| path.start_with?("#{prefix}/") && path.end_with?(".css") }
30
+ .sort
31
+ else
32
+ sprockets_stylesheet_paths(prefix)
33
+ end
34
+ end
35
+
36
+ # Precompiled manifest first (the source tree may be absent in production),
37
+ # then the asset search paths. Sprockets resolves and digests the names itself.
38
+ def sprockets_stylesheet_paths(prefix)
39
+ manifest = Rails.application.respond_to?(:assets_manifest) ? Rails.application.assets_manifest : nil
40
+ listed = manifest ? manifest.assets.keys.select { |k| k.start_with?("#{prefix}/") && k.end_with?(".css") } : []
41
+ return listed.sort unless listed.empty?
42
+
43
+ Rails.application.config.assets.paths
44
+ .flat_map { |dir| Dir[File.join(dir.to_s, prefix, "*.css")] }
45
+ .map { |f| "#{prefix}/#{File.basename(f)}" }
46
+ .uniq.sort
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,7 @@
1
+ module EngineMap
2
+ class Railtie < ::Rails::Railtie
3
+ initializer "engine_map.helpers" do
4
+ ActiveSupport.on_load(:action_view) { include EngineMap::Helpers }
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,3 @@
1
+ module EngineMap
2
+ VERSION = "0.1.0"
3
+ end
data/lib/engine_map.rb ADDED
@@ -0,0 +1,146 @@
1
+ require "engine_map/version"
2
+ require "engine_map/helpers"
3
+ require "engine_map/railtie"
4
+
5
+ module EngineMap
6
+ Registration = Struct.new(:name, :root, :importmap_path, keyword_init: true)
7
+
8
+ class << self
9
+ # Engines that called configure, as { "admin" => :merge }. Filled at boot.
10
+ def configured
11
+ @configured ||= {}
12
+ end
13
+
14
+ def registry
15
+ @registry ||= {}
16
+ end
17
+
18
+ # EngineMap.register "admin", root: Admin::Engine.root
19
+ def register(name, root:, importmap: nil)
20
+ name = name.to_s
21
+ root = Pathname.new(root)
22
+ registry[name] = Registration.new(
23
+ name: name, root: root,
24
+ importmap_path: Pathname.new(importmap || root.join("config/importmap.rb"))
25
+ )
26
+ Helpers.define_importmap_helper(name)
27
+ registry[name]
28
+ end
29
+
30
+ # Isolated engines keep their name (it matches their asset folder).
31
+ # Plain engines lose the "_engine" suffix Rails appends.
32
+ def name_for(engine_class)
33
+ base = engine_class.engine_name.to_s
34
+ engine_class.railtie_namespace ? base : base.delete_suffix("_engine")
35
+ end
36
+
37
+ # Called from an engine class body:
38
+ # EngineMap.configure(self) # merge into host importmap (default)
39
+ # EngineMap.configure(self, importmap: :own) # independent map
40
+ # EngineMap.configure(self, importmap: :both)
41
+ # The name is resolved inside initializers, after the class body (and
42
+ # isolate_namespace) has run.
43
+ def configure(engine, importmap: :merge, name: nil)
44
+ unless %i[merge own both].include?(importmap)
45
+ raise ArgumentError, "importmap must be :merge, :own or :both (got #{importmap.inspect})"
46
+ end
47
+ id = engine.name
48
+
49
+ engine.initializer "engine_map.record.#{id}" do
50
+ EngineMap.configured[name || EngineMap.name_for(engine)] = importmap
51
+ end
52
+
53
+ engine.initializer "engine_map.assets.#{id}" do |app|
54
+ js = engine.root.join("app/javascript")
55
+ app.config.assets.paths << js.to_s if app.config.respond_to?(:assets) && js.exist?
56
+ end
57
+
58
+ # Sprockets only serves what is listed for precompile. Propshaft needs nothing.
59
+ engine.initializer "engine_map.precompile.#{id}", after: "engine_map.assets.#{id}" do |app|
60
+ next if defined?(Propshaft) || !app.config.respond_to?(:assets) || !app.config.assets.respond_to?(:precompile)
61
+ EngineMap.sprockets_logical_names(engine.root, name || EngineMap.name_for(engine)).each do |logical|
62
+ app.config.assets.precompile << logical
63
+ end
64
+ end
65
+
66
+ # Make importmap-rails notice new or changed engine JS (host sweeper only
67
+ # watches the host's folders). Must run before importmap builds its watcher.
68
+ engine.initializer "engine_map.sweeper.#{id}", before: "importmap.cache_sweeper" do |app|
69
+ js = engine.root.join("app/javascript")
70
+ if EngineMap.reloading?(app) && js.exist? && app.config.respond_to?(:importmap)
71
+ app.config.importmap.cache_sweepers << js
72
+ end
73
+ end
74
+
75
+ if %i[merge both].include?(importmap)
76
+ engine.initializer "engine_map.importmap_merge.#{id}", before: "importmap" do |app|
77
+ file = engine.root.join("config/importmap.rb")
78
+ app.config.importmap.paths << file if file.exist? && app.config.respond_to?(:importmap)
79
+ end
80
+ end
81
+
82
+ if %i[own both].include?(importmap)
83
+ engine.initializer "engine_map.importmap_own.#{id}" do
84
+ EngineMap.register(name || EngineMap.name_for(engine), root: engine.root,
85
+ importmap: engine.root.join("config/importmap.rb"))
86
+ end
87
+ end
88
+ end
89
+
90
+ # Logical names (e.g. "admin/base.css") of an engine's own assets, for
91
+ # config.assets.precompile. Sprockets needs plain strings, not globs or procs.
92
+ def sprockets_logical_names(root, name)
93
+ css = Dir[root.join("app/assets/stylesheets", name, "*.css")].map { |f| "#{name}/#{File.basename(f)}" }
94
+ js = Dir[root.join("app/javascript", name, "**/*.js")].map do |f|
95
+ f.delete_prefix("#{root.join('app/javascript')}/")
96
+ end
97
+ css + js
98
+ end
99
+
100
+ # Entry point the engine-only helper imports. Engines with their own
101
+ # Stimulus app pin "<name>/standalone"; everyone else uses application.
102
+ def default_entry(name)
103
+ file = registry.fetch(name.to_s).importmap_path
104
+ standalone = file.exist? && file.read.include?(%(pin "#{name}/standalone"))
105
+ standalone ? "#{name}/standalone" : "#{name}/application"
106
+ end
107
+
108
+ def reloading?(app = Rails.application)
109
+ app.config.respond_to?(:reloading_enabled?) && app.config.reloading_enabled?
110
+ end
111
+
112
+ def importmap_for(name)
113
+ watchers[name.to_s]&.execute_if_updated
114
+ registration = registry.fetch(name.to_s) do
115
+ raise ArgumentError, "No engine registered as #{name.inspect}. Registered: #{registry.keys.inspect}"
116
+ end
117
+ maps[registration.name] ||= build_map(registration).tap { watch(registration) }
118
+ end
119
+
120
+ private
121
+
122
+ def watchers
123
+ @watchers ||= {}
124
+ end
125
+
126
+ # Dev only: drop the cached map when the engine's importmap file or JS changes.
127
+ def watch(registration)
128
+ return unless reloading?
129
+ dirs = [registration.root.join("app/javascript")].select(&:exist?).to_h { |d| [d.to_s, ["js"]] }
130
+ watchers[registration.name] = Rails.application.config.file_watcher.new([registration.importmap_path.to_s], dirs) do
131
+ maps.delete(registration.name)
132
+ end
133
+ end
134
+
135
+ def maps
136
+ @maps ||= {}
137
+ end
138
+
139
+ def build_map(registration)
140
+ unless defined?(Importmap::Map)
141
+ raise LoadError, "importmap-rails is required for #{registration.name}_javascript_importmap_tags"
142
+ end
143
+ Importmap::Map.new.tap { |map| map.draw(registration.importmap_path) }
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,217 @@
1
+ require "rails/generators"
2
+ require "digest"
3
+ require "engine_map/generators/safe_files"
4
+ require "engine_map/generators/engine_picker"
5
+
6
+ module EngineMap
7
+ module Generators
8
+ class InstallGenerator < Rails::Generators::Base
9
+ include SafeFiles
10
+ source_root File.expand_path("templates", __dir__)
11
+ desc "Set up engine asset helpers (stylesheets and importmap) in an engine."
12
+
13
+ ENGINE_CLASS = /class \w+ < (::)?Rails::Engine/
14
+ IMPORTMAP_TAGS = /<%= javascript_importmap_tags[^%]*%>/
15
+ STYLESHEET_TAG = /<%= stylesheet_link_tag [^%]*%>/
16
+
17
+ class_option :engine, type: :string, desc: "Engine name (skips the prompt)"
18
+ class_option :importmap, type: :string, default: "merge", enum: %w[merge own both],
19
+ desc: "merge into host importmap, own independent map, or both"
20
+ class_option :stimulus, type: :string, default: "host", enum: %w[host own none],
21
+ desc: "host Stimulus app, engine's own Stimulus app, or no controllers"
22
+ class_option :all, type: :boolean, default: false, desc: "Also list engines from installed gems"
23
+
24
+ def choose_target
25
+ picker = EnginePicker.new(candidates, app: app, all: options[:all])
26
+ @target = options[:engine] ? picker.find(options[:engine]) : (picker.default || prompt_for(picker))
27
+ raise Thor::Error, "No engine named #{options[:engine].inspect}. Try --all." unless @target
28
+
29
+ self.destination_root = @target.root.to_s
30
+ say_status :target, @target.label, :green
31
+ @before = snapshot
32
+ end
33
+
34
+ def configure_engine
35
+ return if host?
36
+ file = engine_file
37
+ return manual("lib/<engine>/engine.rb", configure_line, "engine class not found", :yellow) unless file
38
+ safe_inject file, %(require "engine_map"), before: /\A/
39
+ return if File.read(File.join(destination_root, file)).include?("EngineMap.configure")
40
+
41
+ safe_inject file, configure_line, after: ENGINE_CLASS
42
+ end
43
+
44
+ def add_gem_dependency
45
+ return if host?
46
+ spec = Dir[File.join(destination_root, "*.gemspec")].first
47
+ return needs_manual("no gemspec found (add `spec.add_dependency \"engine_map\"` by hand)") unless spec
48
+ safe_inject relative(spec), %( spec.add_dependency "engine_map"), after: /^\s*spec\.add_dependency [^\n]*/
49
+ end
50
+
51
+ def create_javascript
52
+ return if host?
53
+ template "application.js.tt", "app/javascript/#{name}/application.js", skip: true
54
+ unless stimulus == "none"
55
+ template "example_controller.js.tt", "app/javascript/#{name}/controllers/example_controller.js", skip: true
56
+ end
57
+ template "standalone.js.tt", "app/javascript/#{name}/standalone.js", skip: true if stimulus == "own"
58
+ if File.exist?(File.join(destination_root, "config/importmap.rb"))
59
+ safe_inject "config/importmap.rb", importmap_lines, after: /\A[^\n]*/
60
+ else
61
+ template "importmap.rb.tt", "config/importmap.rb"
62
+ end
63
+ end
64
+
65
+ def create_stylesheet
66
+ return if host?
67
+ template "application.css.tt", "app/assets/stylesheets/#{name}/application.css", skip: true
68
+ end
69
+
70
+ def create_standalone_layout
71
+ return if host? || options[:importmap] != "both"
72
+ template "standalone.html.erb.tt", "app/views/layouts/#{name}/standalone.html.erb", skip: true
73
+ end
74
+
75
+ def update_layout
76
+ return if host?
77
+ layout = layout_file
78
+ unless layout
79
+ needs_manual("no layout found (add these inside <head> of the layout your engine uses)")
80
+ js = if options[:importmap] == "own"
81
+ %( <%= #{name}_javascript_importmap_tags %>)
82
+ else
83
+ %( <%= javascript_importmap_tags %>\n <%= javascript_import_module_tag "#{name}/application" %>)
84
+ end
85
+ say %( <%= engine_stylesheet_link_tags "#{name}", :all %>\n#{js}), :cyan
86
+ return
87
+ end
88
+
89
+ add_stylesheet_tag(layout)
90
+ has_tags = File.read(File.join(destination_root, layout)).match?(IMPORTMAP_TAGS)
91
+ if options[:importmap] == "own"
92
+ if has_tags
93
+ safe_replace layout, /(?<!\w)javascript_importmap_tags/, "#{name}_javascript_importmap_tags"
94
+ else
95
+ safe_inject layout, %( <%= #{name}_javascript_importmap_tags %>), before: %r{^\s*</head>}
96
+ end
97
+ elsif has_tags
98
+ safe_inject layout, %( <%= javascript_import_module_tag "#{name}/application" %>), after: IMPORTMAP_TAGS
99
+ else
100
+ safe_inject layout, %( <%= javascript_importmap_tags %>\n <%= javascript_import_module_tag "#{name}/application" %>),
101
+ before: %r{^\s*</head>}
102
+ end
103
+ end
104
+
105
+ def wire_host
106
+ return unless host?
107
+ layout = "app/views/layouts/application.html.erb"
108
+ path = File.join(destination_root, layout)
109
+ if File.exist?(path) && !File.read(path).match?(IMPORTMAP_TAGS)
110
+ needs_manual("#{layout} has no javascript_importmap_tags (add it before </head>)")
111
+ end
112
+ check_sprockets_manifest
113
+ import_configured_engines
114
+ end
115
+
116
+ # Must stay the last public method: Rails runs public methods in order.
117
+ def finish
118
+ if @manual_steps.to_i > 0
119
+ say "\n#{@manual_steps} step(s) need a manual edit (see the 'manual' lines above).", :yellow
120
+ elsif snapshot == @before
121
+ say "\nEverything is already set up. Nothing left to do, please proceed.", :green
122
+ else
123
+ say "\nInstall complete. Backups (if any) are saved as *.backup next to the edited files.", :green
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def check_sprockets_manifest
130
+ return if defined?(Propshaft)
131
+ manifest = "app/assets/config/manifest.js"
132
+ return needs_manual("#{manifest} not found (Sprockets must link app/javascript)") unless File.exist?(File.join(destination_root, manifest))
133
+ return if File.read(File.join(destination_root, manifest)).include?("javascript")
134
+ safe_inject manifest, "//= link_tree ../../javascript .js\n//= link_tree ../../../vendor/javascript .js", before: /\A/
135
+ end
136
+
137
+ def import_after_last(entry, line)
138
+ last = File.readlines(File.join(destination_root, entry)).grep(/^import\b.*["'];?\s*$/).last
139
+ if last
140
+ safe_inject entry, line, after: last.chomp
141
+ else
142
+ safe_inject entry, line, before: /\A/
143
+ end
144
+ end
145
+
146
+ def import_configured_engines
147
+ entry = "app/javascript/application.js"
148
+ names = EngineMap.configured.select { |_, mode| %i[merge both].include?(mode) }.keys.sort
149
+ return say_status(:skipped, "no engines using EngineMap in merge mode", :yellow) if names.empty?
150
+ return needs_manual("#{entry} not found (import each engine's application module by hand)") unless File.exist?(File.join(destination_root, entry))
151
+ names.each { |n| import_after_last(entry, %(import "#{n}/application")) }
152
+ end
153
+
154
+ def snapshot
155
+ files = Dir[File.join(destination_root, "{app,config,lib}/**/*"), File.join(destination_root, "*.gemspec")]
156
+ files.select { |f| File.file?(f) }.sort.map { |f| [f, Digest::MD5.file(f).hexdigest] }
157
+ end
158
+
159
+ def candidates = Rails.application.railties.select { |r| r.is_a?(Rails::Engine) }
160
+ def app = Rails.application
161
+ def host? = @target.host
162
+ def name = @target.name
163
+ def stimulus = options[:stimulus]
164
+
165
+ def add_stylesheet_tag(layout)
166
+ tag = %(<%= engine_stylesheet_link_tags "#{name}", :all %>)
167
+ content = File.read(File.join(destination_root, layout))
168
+ own = /<%= stylesheet_link_tag\s+["']#{Regexp.escape(name)}\/application["'][^%]*%>/
169
+ if content.match?(own)
170
+ safe_replace layout, own, tag
171
+ elsif content.match?(STYLESHEET_TAG)
172
+ safe_inject layout, " #{tag}", after: STYLESHEET_TAG
173
+ else
174
+ safe_inject layout, " #{tag}", before: %r{^\s*</head>}
175
+ end
176
+ end
177
+
178
+ def importmap_lines
179
+ lines = [%(pin "#{name}/application")]
180
+ unless stimulus == "none"
181
+ lines << %(pin_all_from File.expand_path("../app/javascript/#{name}/controllers", __dir__),\n) +
182
+ %( under: "controllers/#{name}", to: "#{name}/controllers")
183
+ end
184
+ if stimulus == "own"
185
+ lines << %(pin "#{name}/standalone")
186
+ lines << %(pin "@hotwired/stimulus", to: "stimulus.min.js")
187
+ lines << %(pin "@hotwired/stimulus-loading", to: "stimulus-loading.js")
188
+ end
189
+ lines.join("\n")
190
+ end
191
+
192
+ def configure_line
193
+ arg = options[:importmap] == "merge" ? "" : ", importmap: :#{options[:importmap]}"
194
+ " EngineMap.configure(self#{arg})"
195
+ end
196
+
197
+ def relative(path) = Pathname.new(path).relative_path_from(Pathname.new(destination_root)).to_s
198
+
199
+ def engine_file
200
+ found = Dir[File.join(destination_root, "lib/**/*.rb")].find { |f| File.read(f).match?(ENGINE_CLASS) }
201
+ found && relative(found)
202
+ end
203
+
204
+ def layout_file
205
+ found = Dir[File.join(destination_root, "app/views/layouts/**/application.html.erb")].first
206
+ found && relative(found)
207
+ end
208
+
209
+ def prompt_for(picker)
210
+ say "Where should EngineMap be installed?", :bold
211
+ picker.targets.each_with_index { |t, i| say " #{i + 1}. #{t.label}", :cyan }
212
+ answer = ask("Number:", limited_to: (1..picker.targets.size).map(&:to_s))
213
+ picker.targets[answer.to_i - 1]
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,3 @@
1
+ /* Styles for <%= @target.name %>. Every .css file in this folder is linked
2
+ * automatically by engine_stylesheet_link_tags "<%= @target.name %>", :all
3
+ * (alphabetical order, so application.css loads first). */
@@ -0,0 +1,3 @@
1
+ // JavaScript entry point for <%= @target.name %>.
2
+ // Merged mode: imported by the layout via javascript_import_module_tag.
3
+ // Own mode: imported by <%= @target.name %>_javascript_importmap_tags.
@@ -0,0 +1,6 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Use in views as: data-controller="<%= @target.name.dasherize %>--example"
4
+ export default class extends Controller {
5
+ connect() {}
6
+ }
@@ -0,0 +1,3 @@
1
+ # Pins for <%= @target.name %>. Merged into the host importmap by default,
2
+ # or used alone via <%= @target.name %>_javascript_importmap_tags.
3
+ <%= importmap_lines %>
@@ -0,0 +1,16 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title><%= @target.name.titleize %></title>
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <%%= csrf_meta_tags %>
7
+ <%%= csp_meta_tag %>
8
+ <%%= yield :head %>
9
+
10
+ <%%= engine_stylesheet_link_tags "<%= @target.name %>", :all %>
11
+ <%%= <%= @target.name %>_javascript_importmap_tags %>
12
+ </head>
13
+ <body>
14
+ <%%= yield %>
15
+ </body>
16
+ </html>
@@ -0,0 +1,10 @@
1
+ // Entry point for <%= @target.name %>'s own importmap only. Never import this
2
+ // from a merged layout: it starts a second Stimulus application.
3
+ import "<%= @target.name %>/application"
4
+ import { Application } from "@hotwired/stimulus"
5
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
6
+
7
+ const application = Application.start()
8
+ eagerLoadControllersFrom("controllers", application)
9
+
10
+ export { application }
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :engine_map do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: engine_map
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Baden
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: railties
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '8.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '8.0'
26
+ description: Stylesheet :all helper, merged or independent importmaps, and namespaced
27
+ Stimulus controllers for Rails engines, on Propshaft or Sprockets, with an idempotent
28
+ install generator that backs up what it edits.
29
+ email:
30
+ - baden.rushworth@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - MIT-LICENSE
37
+ - README.md
38
+ - Rakefile
39
+ - lib/engine_map.rb
40
+ - lib/engine_map/generators/engine_picker.rb
41
+ - lib/engine_map/generators/safe_files.rb
42
+ - lib/engine_map/helpers.rb
43
+ - lib/engine_map/railtie.rb
44
+ - lib/engine_map/version.rb
45
+ - lib/generators/engine_map/install/install_generator.rb
46
+ - lib/generators/engine_map/install/templates/application.css.tt
47
+ - lib/generators/engine_map/install/templates/application.js.tt
48
+ - lib/generators/engine_map/install/templates/example_controller.js.tt
49
+ - lib/generators/engine_map/install/templates/importmap.rb.tt
50
+ - lib/generators/engine_map/install/templates/standalone.html.erb.tt
51
+ - lib/generators/engine_map/install/templates/standalone.js.tt
52
+ - lib/tasks/engine_map_tasks.rake
53
+ homepage: https://github.com/BadenRushworth/engine_map
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/BadenRushworth/engine_map
58
+ source_code_uri: https://github.com/BadenRushworth/engine_map
59
+ changelog_uri: https://github.com/BadenRushworth/engine_map/blob/main/CHANGELOG.md
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.2'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 4.0.16
76
+ specification_version: 4
77
+ summary: Asset helpers and an install generator for Rails engines.
78
+ test_files: []