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 +7 -0
- data/CHANGELOG.md +5 -0
- data/MIT-LICENSE +20 -0
- data/README.md +83 -0
- data/Rakefile +13 -0
- data/lib/engine_map/generators/engine_picker.rb +51 -0
- data/lib/engine_map/generators/safe_files.rb +90 -0
- data/lib/engine_map/helpers.rb +49 -0
- data/lib/engine_map/railtie.rb +7 -0
- data/lib/engine_map/version.rb +3 -0
- data/lib/engine_map.rb +146 -0
- data/lib/generators/engine_map/install/install_generator.rb +217 -0
- data/lib/generators/engine_map/install/templates/application.css.tt +3 -0
- data/lib/generators/engine_map/install/templates/application.js.tt +3 -0
- data/lib/generators/engine_map/install/templates/example_controller.js.tt +6 -0
- data/lib/generators/engine_map/install/templates/importmap.rb.tt +3 -0
- data/lib/generators/engine_map/install/templates/standalone.html.erb.tt +16 -0
- data/lib/generators/engine_map/install/templates/standalone.js.tt +10 -0
- data/lib/tasks/engine_map_tasks.rake +4 -0
- metadata +78 -0
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
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
|
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,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 }
|
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: []
|