iconmap-rails 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ffee894f6afcd5daa549857261eeeb4c663a25992edcc02bd59d442868153d80
4
+ data.tar.gz: a28499b09beb0cf46259bbd7f16a78afadc5a0965c8529f6cc282cb74d94481f
5
+ SHA512:
6
+ metadata.gz: 1b5c60f56f2413639d944e46a6df23174842348399155c42ec64c2817174ee32cdd06b02dd818d1c5043c2886ca8782be22ea04f7dd4de09ebd1376fe9e76424
7
+ data.tar.gz: a7d1717b47946064e9dd44fe7c2d9c2af513967a17dbe00109aa0add0a6b7b8917d19dab7b23a90c9b1547e7017a650f2050bb48f55fcc8be6f0a5ddd48a81fb
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2022 Basecamp, 2025 Geremia Taglialatela
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,266 @@
1
+ # Iconmap for Rails
2
+
3
+ Like [Importmap Rails](https://github.com/rails/importmap-rails), but for Icons.
4
+
5
+ ## Installation
6
+
7
+ Iconmap for Rails is automatically included in Rails 7+ for new applications, but you can also install it manually in existing applications:
8
+
9
+ 1. Run `./bin/bundle add iconmap-rails`
10
+ 2. Run `./bin/rails iconmap:install`
11
+
12
+ You can pin those libraries manually by relying on the compiled versions included in Rails like this:
13
+
14
+ ```ruby
15
+ pin "@rails/actioncable", to: "actioncable.esm.js"
16
+ pin "@rails/activestorage", to: "activestorage.esm.js"
17
+ pin "@rails/actiontext", to: "actiontext.esm.js"
18
+ pin "trix"
19
+ ```
20
+
21
+ ## How do iconmaps work?
22
+
23
+ At their core, iconmaps are essentially a string substitution for what are referred to as "bare module specifiers". A "bare module specifier" looks like this: `import React from "react"`. This is not compatible with the ES Module loader spec. Instead, to be ESM compatible, you must provide 1 of the 3 following types of specifiers:
24
+
25
+ - Absolute path:
26
+ ```js
27
+ import React from "/Users/DHH/projects/basecamp/node_modules/react"
28
+ ```
29
+
30
+ - Relative path:
31
+ ```js
32
+ import React from "./node_modules/react"
33
+ ```
34
+
35
+ - HTTP path:
36
+ ```js
37
+ import React from "https://ga.jspm.io/npm:react@17.0.1/index.js"
38
+ ```
39
+
40
+ Iconmap-rails provides a clean API for mapping "bare module specifiers" like `"react"`
41
+ to 1 of the 3 viable ways of loading ES Module javascript packages.
42
+
43
+ For example:
44
+
45
+ ```rb
46
+ # config/iconmap.rb
47
+ pin "react", to: "https://ga.jspm.io/npm:react@17.0.2/index.js"
48
+ ```
49
+
50
+ means "everytime you see `import React from "react"`
51
+ change it to `import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"`"
52
+
53
+ ```js
54
+ import React from "react"
55
+ // => import React from "https://ga.jspm.io/npm:react@17.0.2/index.js"
56
+ ```
57
+
58
+ ## Usage
59
+
60
+ The icon map is setup through `Rails.application.iconmap` via the configuration in `config/iconmap.rb`. This file is automatically reloaded in development upon changes, but note that you must restart the server if you remove pins and need them gone from the rendered iconmap or list of preloads.
61
+
62
+ It makes sense to use logical names that match the package names used by npm, such that if you later want to start transpiling or bundling your code, you won't have to change any module imports.
63
+
64
+ ### Local modules
65
+
66
+ If you want to import local js module files from `app/javascript/src` or other sub-folders of `app/javascript` (such as `channels`), you must pin these to be able to import them. You can use `pin_all_from` to pick all files in a specific folder, so you don't have to `pin` each module individually.
67
+
68
+ ```rb
69
+ # config/iconmap.rb
70
+ pin_all_from 'app/javascript/src', under: 'src', to: 'src'
71
+ ```
72
+
73
+ The `:to` parameter is only required if you want to change the destination logical import name. If you drop the :to option, you must place the :under option directly after the first parameter.
74
+
75
+ Allows you to:
76
+
77
+ ```js
78
+ // app/javascript/application.js
79
+ import { ExampleFunction } from 'src/example_function'
80
+ ```
81
+ Which imports the function from `app/javascript/src/example_function.js`.
82
+
83
+ Note: Sprockets used to serve assets (albeit without filename digests) it couldn't find from the `app/javascripts` folder with logical relative paths, meaning pinning local files wasn't needed. Propshaft doesn't have this fallback, so when you use Propshaft you have to pin your local modules.
84
+
85
+ ## Using npm packages via JavaScript CDNs
86
+
87
+ Iconmap for Rails downloads and vendors your npm package dependencies via JavaScript CDNs that provide pre-compiled distribution versions.
88
+
89
+ You can use the `./bin/iconmap` command that's added as part of the install to pin, unpin, or update npm packages in your import map. This command uses an API from [JSPM.org](https://jspm.org) to resolve your package dependencies efficiently, and then add the pins to your `config/iconmap.rb` file. It can resolve these dependencies from JSPM itself, but also from other CDNs, like [unpkg.com](https://unpkg.com) and [jsdelivr.com](https://www.jsdelivr.com).
90
+
91
+ ```bash
92
+ ./bin/iconmap pin react
93
+ Pinning "react" to vendor/react.js via download from https://ga.jspm.io/npm:react@17.0.2/index.js
94
+ Pinning "object-assign" to vendor/object-assign.js via download from https://ga.jspm.io/npm:object-assign@4.1.1/index.js
95
+ ```
96
+
97
+ This will produce pins in your `config/iconmap.rb` like so:
98
+
99
+ ```ruby
100
+ pin "react" # https://ga.jspm.io/npm:react@17.0.2/index.js
101
+ pin "object-assign" # https://ga.jspm.io/npm:object-assign@4.1.1/index.js
102
+ ```
103
+
104
+ The packages are downloaded to `vendor/icons`, which you can check into your source control, and they'll be available through your application's own asset pipeline serving.
105
+
106
+ If you later wish to remove a downloaded pin:
107
+
108
+ ```bash
109
+ ./bin/iconmap unpin react
110
+ Unpinning and removing "react"
111
+ Unpinning and removing "object-assign"
112
+ ```
113
+
114
+ ## Preloading pinned modules
115
+
116
+ To avoid the waterfall effect where the browser has to load one file after another before it can get to the deepest nested import, iconmap-rails uses [modulepreload links](https://developers.google.com/web/updates/2017/12/modulepreload) by default. If you don't want to preload a dependency, because you want to load it on-demand for efficiency, append `preload: false` to the pin.
117
+
118
+ Example:
119
+
120
+ ```ruby
121
+ # config/iconmap.rb
122
+ pin "@github/hotkey", to: "@github--hotkey.js" # file lives in vendor/icons/@github--hotkey.js
123
+ pin "md5", preload: false # file lives in vendor/javascript/md5.js
124
+
125
+ # app/views/layouts/application.html.erb
126
+ <%= javascript_iconmap_tags %>
127
+
128
+ # will include the following link before the iconmap is setup:
129
+ <link rel="modulepreload" href="/assets/javascript/@github--hotkey.js">
130
+ ...
131
+ ```
132
+
133
+ You can also specify which entry points to preload a particular dependency in by providing `preload:` a string or array of strings.
134
+
135
+ Example:
136
+
137
+ ```ruby
138
+ # config/iconmap.rb
139
+ pin "@github/hotkey", to: "@github--hotkey.js", preload: 'application'
140
+ pin "md5", preload: ['application', 'alternate']
141
+
142
+ # app/views/layouts/application.html.erb
143
+ <%= javascript_iconmap_tags 'alternate' %>
144
+
145
+ # will include the following link before the iconmap is setup:
146
+ <link rel="modulepreload" href="/assets/javascript/md5.js">
147
+ ...
148
+ ```
149
+
150
+
151
+
152
+ ## Composing import maps
153
+
154
+ By default, Rails loads import map definition from the application's `config/iconmap.rb` to the `Iconmap::Map` object available at `Rails.application.iconmap`.
155
+
156
+ You can combine multiple import maps by adding paths to additional import map configs to `Rails.application.config.iconmap.paths`. For example, appending import maps defined in Rails engines:
157
+
158
+ ```ruby
159
+ # my_engine/lib/my_engine/engine.rb
160
+
161
+ module MyEngine
162
+ class Engine < ::Rails::Engine
163
+ # ...
164
+ initializer "my-engine.iconmap", before: "iconmap" do |app|
165
+ app.config.iconmap.paths << Engine.root.join("config/iconmap.rb")
166
+ # ...
167
+ end
168
+ end
169
+ end
170
+ ```
171
+
172
+ And pinning JavaScript modules from the engine:
173
+
174
+ ```ruby
175
+ # my_engine/config/iconmap.rb
176
+
177
+ pin_all_from File.expand_path("../app/assets/javascripts", __dir__)
178
+ ```
179
+
180
+
181
+ ## Selectively importing modules
182
+
183
+ You can selectively import your javascript modules on specific pages.
184
+
185
+ Create your javascript in `app/javascript`:
186
+
187
+ ```js
188
+ // /app/javascript/checkout.js
189
+ // some checkout specific js
190
+ ```
191
+
192
+ Pin your js file:
193
+
194
+ ```rb
195
+ # config/iconmap.rb
196
+ # ... other pins...
197
+ pin "checkout", preload: false
198
+ ```
199
+
200
+ Import your module on the specific page. Note: you'll likely want to use a `content_for` block on the specifc page/partial, then yield it in your layout.
201
+
202
+ ```erb
203
+ <% content_for :head do %>
204
+ <%= javascript_import_module_tag "checkout" %>
205
+ <% end %>
206
+ ```
207
+
208
+ **Important**: The `javascript_import_module_tag` should come after your `javascript_iconmap_tags`
209
+
210
+ ```erb
211
+ <%= javascript_iconmap_tags %>
212
+ <%= yield(:head) %>
213
+ ```
214
+
215
+
216
+ ## Include a digest of the import map in your ETag
217
+
218
+ If you're using [ETags](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) generated by Rails helpers like `stale?` or `fresh_when`, you need to include the digest of the import map into this calculation. Otherwise your application will return [304](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/304) cache responses even when your JavaScript assets have changed. You can avoid this using the `stale_when_iconmap_changes` method:
219
+
220
+ ```ruby
221
+ class ApplicationController < ActionController::Base
222
+ stale_when_iconmap_changes
223
+ end
224
+ ```
225
+
226
+ This will add the digest of the iconmap to the etag calculation when the request format is HTML.
227
+
228
+
229
+ ## Sweeping the cache in development and test
230
+
231
+ Generating the import map json and modulepreloads may require resolving hundreds of assets. This can take a while, so these operations are cached, but in development and test, we watch for changes to both `config/iconmap.rb` and files in `app/javascript` to clear this cache. This feature can be controlled in an environment configuration file via the boolean `config.iconmap.sweep_cache`.
232
+
233
+ If you're pinning local files from outside of `app/javascript`, you'll need to add them to the cache sweeper configuration or restart your development server upon changes to those external files. For example, here's how you can do it for Rails engine:
234
+
235
+ ```ruby
236
+ # my_engine/lib/my_engine/engine.rb
237
+
238
+ module MyEngine
239
+ class Engine < ::Rails::Engine
240
+ # ...
241
+ initializer "my-engine.iconmap", before: "iconmap" do |app|
242
+ # ...
243
+ app.config.iconmap.cache_sweepers << Engine.root.join("app/assets/icons")
244
+ end
245
+ end
246
+ end
247
+ ```
248
+
249
+ ## Checking for outdated or vulnerable packages
250
+
251
+ Iconmap for Rails provides two commands to check your pinned packages:
252
+ - `./bin/iconmap outdated` checks the NPM registry for new versions
253
+ - `./bin/iconmap audit` checks the NPM registry for known security issues
254
+
255
+ ## Supporting legacy browsers such as Safari on iOS 15
256
+
257
+ If you want to support [legacy browsers that do not support import maps](https://caniuse.com/import-maps) such as [iOS 15.8.1 released on 22 Jan 2024](https://support.apple.com/en-us/HT201222), insert [`es-module-shims`](https://github.com/guybedford/es-module-shims) before `javascript_iconmap_tags` as below.
258
+
259
+ ```erb
260
+ <script async src="https://ga.jspm.io/npm:es-module-shims@1.8.2/dist/es-module-shims.js" data-turbo-track="reload"></script>
261
+ <%= javascript_iconmap_tags %>
262
+ ```
263
+
264
+ ## License
265
+
266
+ Iconmap for Rails is released under the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ load "rails/tasks/statistics.rake"
7
+
8
+ require "bundler/gem_tasks"
9
+
10
+ require "rake/testtask"
11
+
12
+ Rake::TestTask.new(:test) do |t|
13
+ t.libs << 'test'
14
+ t.pattern = 'test/**/*_test.rb'
15
+ t.verbose = false
16
+ t.warning = false
17
+ end
18
+
19
+ task default: :test
@@ -0,0 +1,5 @@
1
+ module Iconmap::Freshness
2
+ def stale_when_iconmap_changes
3
+ etag { Rails.application.iconmap.digest(resolver: helpers) if request.format&.html? }
4
+ end
5
+ end
@@ -0,0 +1,37 @@
1
+ module Iconmap::IconmapTagsHelper
2
+ # Setup all script tags needed to use an iconmap-powered entrypoint (which defaults to application.js)
3
+ def javascript_iconmap_tags(entry_point = "application", iconmap: Rails.application.iconmap)
4
+ safe_join [
5
+ javascript_inline_iconmap_tag(iconmap.to_json(resolver: self)),
6
+ javascript_iconmap_module_preload_tags(iconmap, entry_point:),
7
+ javascript_import_module_tag(entry_point)
8
+ ], "\n"
9
+ end
10
+
11
+ # Generate an inline iconmap tag using the passed `iconmap_json` JSON string.
12
+ # By default, `Rails.application.iconmap.to_json(resolver: self)` is used.
13
+ def javascript_inline_iconmap_tag(iconmap_json = Rails.application.iconmap.to_json(resolver: self))
14
+ tag.script iconmap_json.html_safe,
15
+ type: "iconmap", "data-turbo-track": "reload", nonce: request&.content_security_policy_nonce
16
+ end
17
+
18
+ # Import a named JavaScript module(s) using a script-module tag.
19
+ def javascript_import_module_tag(*module_names)
20
+ imports = Array(module_names).collect { |m| %(import "#{m}") }.join("\n")
21
+ tag.script imports.html_safe, type: "module", nonce: request&.content_security_policy_nonce
22
+ end
23
+
24
+ # Link tags for preloading all modules marked as preload: true in the `iconmap`
25
+ # (defaults to Rails.application.iconmap), such that they'll be fetched
26
+ # in advance by browsers supporting this link type (https://caniuse.com/?search=modulepreload).
27
+ def javascript_iconmap_module_preload_tags(iconmap = Rails.application.iconmap, entry_point: "application")
28
+ javascript_module_preload_tag(*iconmap.preloaded_module_paths(resolver: self, entry_point:, cache_key: entry_point))
29
+ end
30
+
31
+ # Link tag(s) for preloading the JavaScript module residing in `*paths`. Will return one link tag per path element.
32
+ def javascript_module_preload_tag(*paths)
33
+ safe_join(Array(paths).collect { |path|
34
+ tag.link rel: "modulepreload", href: path, nonce: request&.content_security_policy_nonce
35
+ }, "\n")
36
+ end
37
+ end
@@ -0,0 +1,159 @@
1
+ require "thor"
2
+ require_relative "packager"
3
+ require_relative "npm"
4
+
5
+ class Iconmap::Commands < Thor
6
+ include Thor::Actions
7
+
8
+ def self.exit_on_failure?
9
+ false
10
+ end
11
+
12
+ desc "pin [*PACKAGES]", "Pin new icons"
13
+ option :env, type: :string, aliases: :e, default: "production"
14
+ option :from, type: :string, aliases: :f, default: "jspm"
15
+ def pin(*packages)
16
+ if imports = packager.import(*packages, env: options[:env], from: options[:from])
17
+ imports.each do |package, url|
18
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package} via download from #{url})
19
+ packager.download(package, url)
20
+ pin = packager.vendored_pin_for(package, url)
21
+
22
+ if packager.packaged?(package)
23
+ gsub_file("config/iconmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
24
+ else
25
+ append_to_file("config/iconmap.rb", "#{pin}\n", verbose: false)
26
+ end
27
+ end
28
+ else
29
+ puts "Couldn't find any icons in #{packages.inspect} on #{options[:from]}"
30
+ end
31
+ end
32
+
33
+ desc "unpin [*PACKAGES]", "Unpin existing packages"
34
+ option :env, type: :string, aliases: :e, default: "production"
35
+ option :from, type: :string, aliases: :f, default: "jspm"
36
+ def unpin(*packages)
37
+ if imports = packager.import(*packages, env: options[:env], from: options[:from])
38
+ imports.each do |package, url|
39
+ if packager.packaged?(package)
40
+ puts %(Unpinning and removing "#{package}")
41
+ packager.remove(package)
42
+ end
43
+ end
44
+ else
45
+ puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
46
+ end
47
+ end
48
+
49
+ desc "pristine", "Redownload all pinned packages"
50
+ option :env, type: :string, aliases: :e, default: "production"
51
+ option :from, type: :string, aliases: :f, default: "jspm"
52
+ def pristine
53
+ packages = npm.packages_with_versions.map do |p, v|
54
+ v.blank? ? p : [p, v].join("@")
55
+ end
56
+
57
+ if imports = packager.import(*packages, env: options[:env], from: options[:from])
58
+ imports.each do |package, url|
59
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package} from #{url})
60
+ packager.download(package, url)
61
+ end
62
+ else
63
+ puts "Couldn't find any packages in #{packages.inspect} on #{options[:from]}"
64
+ end
65
+ end
66
+
67
+ desc "json", "Show the full iconmap in json"
68
+ def json
69
+ require Rails.root.join("config/environment")
70
+ puts Rails.application.iconmap.to_json(resolver: ActionController::Base.helpers)
71
+ end
72
+
73
+ desc "audit", "Run a security audit"
74
+ def audit
75
+ vulnerable_packages = npm.vulnerable_packages
76
+
77
+ if vulnerable_packages.any?
78
+ table = [["Package", "Severity", "Vulnerable versions", "Vulnerability"]]
79
+ vulnerable_packages.each { |p| table << [p.name, p.severity, p.vulnerable_versions, p.vulnerability] }
80
+
81
+ puts_table(table)
82
+ vulnerabilities = 'vulnerability'.pluralize(vulnerable_packages.size)
83
+ severities = vulnerable_packages.map(&:severity).tally.sort_by(&:last).reverse
84
+ .map { |severity, count| "#{count} #{severity}" }
85
+ .join(", ")
86
+ puts " #{vulnerable_packages.size} #{vulnerabilities} found: #{severities}"
87
+
88
+ exit 1
89
+ else
90
+ puts "No vulnerable packages found"
91
+ end
92
+ end
93
+
94
+ desc "outdated", "Check for outdated packages"
95
+ def outdated
96
+ if (outdated_packages = npm.outdated_packages).any?
97
+ table = [["Icon", "Current", "Latest"]]
98
+ outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] }
99
+
100
+ puts_table(table)
101
+ packages = 'icon'.pluralize(outdated_packages.size)
102
+ puts " #{outdated_packages.size} outdated #{packages} found"
103
+
104
+ exit 1
105
+ else
106
+ puts "No outdated icons found"
107
+ end
108
+ end
109
+
110
+ desc "update", "Update outdated icon pins"
111
+ def update
112
+ if (outdated_packages = npm.outdated_packages).any?
113
+ pin(*outdated_packages.map(&:name))
114
+ else
115
+ puts "No outdated icons found"
116
+ end
117
+ end
118
+
119
+ desc "packages", "Print out icons with version numbers"
120
+ def packages
121
+ puts npm.packages_with_versions.map { |x| x.join(' ') }
122
+ end
123
+
124
+ private
125
+ def packager
126
+ @packager ||= Iconmap::Packager.new
127
+ end
128
+
129
+ def npm
130
+ @npm ||= Iconmap::Npm.new
131
+ end
132
+
133
+ def remove_line_from_file(path, pattern)
134
+ path = File.expand_path(path, destination_root)
135
+
136
+ all_lines = File.readlines(path)
137
+ with_lines_removed = all_lines.select { |line| line !~ pattern }
138
+
139
+ File.open(path, "w") do |file|
140
+ with_lines_removed.each { |line| file.write(line) }
141
+ end
142
+ end
143
+
144
+ def puts_table(array)
145
+ column_sizes = array.reduce([]) do |lengths, row|
146
+ row.each_with_index.map{ |iterand, index| [lengths[index] || 0, iterand.to_s.length].max }
147
+ end
148
+
149
+ divider = "|" + (column_sizes.map { |s| "-" * (s + 2) }.join('|')) + '|'
150
+ array.each_with_index do |row, row_number|
151
+ row = row.fill(nil, row.size..(column_sizes.size - 1))
152
+ row = row.each_with_index.map { |v, i| v.to_s + " " * (column_sizes[i] - v.to_s.length) }
153
+ puts "| " + row.join(" | ") + " |"
154
+ puts divider if row_number == 0
155
+ end
156
+ end
157
+ end
158
+
159
+ Iconmap::Commands.start(ARGV)
@@ -0,0 +1,71 @@
1
+ require_relative "map"
2
+
3
+ # Use Rails.application.iconmap to access the map
4
+ Rails::Application.send(:attr_accessor, :iconmap)
5
+
6
+ module Iconmap
7
+ class Engine < ::Rails::Engine
8
+ config.iconmap = ActiveSupport::OrderedOptions.new
9
+ config.iconmap.paths = []
10
+ config.iconmap.sweep_cache = Rails.env.development? || Rails.env.test?
11
+ config.iconmap.cache_sweepers = []
12
+ config.iconmap.rescuable_asset_errors = []
13
+
14
+ config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
15
+
16
+ initializer "iconmap" do |app|
17
+ app.iconmap = Iconmap::Map.new
18
+ app.config.iconmap.paths << app.root.join("config/iconmap.rb")
19
+ app.config.iconmap.paths.each { |path| app.iconmap.draw(path) }
20
+ end
21
+
22
+ initializer "iconmap.reloader" do |app|
23
+ unless app.config.cache_classes
24
+ Iconmap::Reloader.new.tap do |reloader|
25
+ reloader.execute
26
+ app.reloaders << reloader
27
+ app.reloader.to_run { reloader.execute }
28
+ end
29
+ end
30
+ end
31
+
32
+ initializer "iconmap.cache_sweeper" do |app|
33
+ if app.config.iconmap.sweep_cache && !app.config.cache_classes
34
+ app.config.iconmap.cache_sweepers << app.root.join("vendor/icons")
35
+ app.iconmap.cache_sweeper(watches: app.config.iconmap.cache_sweepers)
36
+
37
+ ActiveSupport.on_load(:action_controller_base) do
38
+ before_action { Rails.application.iconmap.cache_sweeper.execute_if_updated }
39
+ end
40
+ end
41
+ end
42
+
43
+ initializer "iconmap.assets" do |app|
44
+ if app.config.respond_to?(:assets)
45
+ app.config.assets.paths << Rails.root.join("vendor/icons")
46
+ end
47
+ end
48
+
49
+ initializer "iconmap.concerns" do
50
+ ActiveSupport.on_load(:action_controller_base) do
51
+ extend Iconmap::Freshness
52
+ end
53
+ end
54
+
55
+ initializer "iconmap.helpers" do
56
+ ActiveSupport.on_load(:action_controller_base) do
57
+ helper Iconmap::IconmapTagsHelper
58
+ end
59
+ end
60
+
61
+ initializer "iconmap.rescuable_asset_errors" do |app|
62
+ if defined?(Propshaft)
63
+ app.config.iconmap.rescuable_asset_errors << Propshaft::MissingAssetError
64
+ end
65
+
66
+ if defined?(Sprockets::Rails)
67
+ app.config.iconmap.rescuable_asset_errors << Sprockets::Rails::Helper::AssetNotFound
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,168 @@
1
+ require "pathname"
2
+
3
+ class Iconmap::Map
4
+ attr_reader :packages, :directories
5
+
6
+ class InvalidFile < StandardError; end
7
+
8
+ def initialize
9
+ @packages, @directories = {}, {}
10
+ @cache = {}
11
+ end
12
+
13
+ def draw(path = nil, &block)
14
+ if path && File.exist?(path)
15
+ begin
16
+ instance_eval(File.read(path), path.to_s)
17
+ rescue StandardError => e
18
+ Rails.logger.error "Unable to parse import map from #{path}: #{e.message}"
19
+ raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
20
+ end
21
+ elsif block_given?
22
+ instance_eval(&block)
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ def pin(name, to: nil, preload: true)
29
+ clear_cache
30
+ @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload)
31
+ end
32
+
33
+ def pin_all_from(dir, under: nil, to: nil, preload: true)
34
+ clear_cache
35
+ @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload)
36
+ end
37
+
38
+ # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
39
+ # `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the
40
+ # resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to
41
+ # resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
42
+ # the different cases.
43
+ def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths)
44
+ cache_as(cache_key) do
45
+ resolve_asset_paths(expanded_preloading_packages_and_directories(entry_point:), resolver:).values
46
+ end
47
+ end
48
+
49
+ # Returns a JSON hash (as a string) of all the resolved module paths of the pinned packages in the import map format.
50
+ # The `resolver` must respond to `path_to_asset`, such as `ActionController::Base.helpers` or
51
+ # `ApplicationController.helpers`. You'll want to use the resolver that has been configured for the `asset_host` you
52
+ # want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom
53
+ # `cache_key` to vary the cache used by this method for the different cases.
54
+ def to_json(resolver:, cache_key: :json)
55
+ cache_as(cache_key) do
56
+ JSON.pretty_generate({ "imports" => resolve_asset_paths(expanded_packages_and_directories, resolver: resolver) })
57
+ end
58
+ end
59
+
60
+ # Returns a SHA1 digest of the import map json that can be used as a part of a page etag to
61
+ # ensure that a html cache is invalidated when the import map is changed.
62
+ #
63
+ # Example:
64
+ #
65
+ # class ApplicationController < ActionController::Base
66
+ # etag { Rails.application.iconmap.digest(resolver: helpers) if request.format&.html? }
67
+ # end
68
+ def digest(resolver:)
69
+ Digest::SHA1.hexdigest(to_json(resolver: resolver).to_s)
70
+ end
71
+
72
+ # Returns an instance of ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map
73
+ # when the directories passed on initialization via `watches:` have changes. This is used in development
74
+ # and test to ensure the map caches are reset when javascript files are changed.
75
+ def cache_sweeper(watches: nil)
76
+ if watches
77
+ @cache_sweeper =
78
+ Rails.application.config.file_watcher.new([], Array(watches).collect { |dir| [ dir.to_s, "js"] }.to_h) do
79
+ clear_cache
80
+ end
81
+ else
82
+ @cache_sweeper
83
+ end
84
+ end
85
+
86
+ private
87
+ MappedDir = Struct.new(:dir, :path, :under, :preload, keyword_init: true)
88
+ MappedFile = Struct.new(:name, :path, :preload, keyword_init: true)
89
+
90
+ def cache_as(name)
91
+ if result = @cache[name.to_s]
92
+ result
93
+ else
94
+ @cache[name.to_s] = yield
95
+ end
96
+ end
97
+
98
+ def clear_cache
99
+ @cache.clear
100
+ end
101
+
102
+ def rescuable_asset_error?(error)
103
+ Rails.application.config.iconmap.rescuable_asset_errors.any? { |e| error.is_a?(e) }
104
+ end
105
+
106
+ def resolve_asset_paths(paths, resolver:)
107
+ paths.transform_values do |mapping|
108
+ begin
109
+ resolver.path_to_asset(mapping.path)
110
+ rescue => e
111
+ if rescuable_asset_error?(e)
112
+ Rails.logger.warn "Iconmap skipped missing path: #{mapping.path}"
113
+ nil
114
+ else
115
+ raise e
116
+ end
117
+ end
118
+ end.compact
119
+ end
120
+
121
+ def expanded_preloading_packages_and_directories(entry_point:)
122
+ expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
123
+ end
124
+
125
+ def expanded_packages_and_directories
126
+ @packages.dup.tap { |expanded| expand_directories_into expanded }
127
+ end
128
+
129
+ def expand_directories_into(paths)
130
+ @directories.values.each do |mapping|
131
+ if (absolute_path = absolute_root_of(mapping.dir)).exist?
132
+ find_javascript_files_in_tree(absolute_path).each do |filename|
133
+ module_filename = filename.relative_path_from(absolute_path)
134
+ module_name = module_name_from(module_filename, mapping)
135
+ module_path = module_path_from(module_filename, mapping)
136
+
137
+ paths[module_name] = MappedFile.new(name: module_name, path: module_path, preload: mapping.preload)
138
+ end
139
+ end
140
+ end
141
+ end
142
+
143
+ def module_name_from(filename, mapping)
144
+ # Regex explanation:
145
+ # (?:\/|^) # Matches either / OR the start of the string
146
+ # index # Matches the word index
147
+ # $ # Matches the end of the string
148
+ #
149
+ # Sample matches
150
+ # index
151
+ # folder/index
152
+ index_regex = /(?:\/|^)index$/
153
+
154
+ [ mapping.under, filename.to_s.remove(filename.extname).remove(index_regex).presence ].compact.join("/")
155
+ end
156
+
157
+ def module_path_from(filename, mapping)
158
+ [ mapping.path || mapping.under, filename.to_s ].compact.reject(&:empty?).join("/")
159
+ end
160
+
161
+ def find_javascript_files_in_tree(path)
162
+ Dir[path.join("**/*.js{,m}")].sort.collect { |file| Pathname.new(file) }.select(&:file?)
163
+ end
164
+
165
+ def absolute_root_of(path)
166
+ (pathname = Pathname.new(path)).absolute? ? pathname : Rails.root.join(path)
167
+ end
168
+ end
@@ -0,0 +1,129 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ class Iconmap::Npm
6
+ Error = Class.new(StandardError)
7
+ HTTPError = Class.new(Error)
8
+
9
+ singleton_class.attr_accessor :base_uri
10
+ self.base_uri = URI("https://registry.npmjs.org")
11
+
12
+ def initialize(iconmap_path = "config/iconmap.rb")
13
+ @iconmap_path = Pathname.new(iconmap_path)
14
+ end
15
+
16
+ def outdated_packages
17
+ packages_with_versions.each.with_object([]) do |(package, current_version), outdated_packages|
18
+ outdated_package = OutdatedPackage.new(name: package,
19
+ current_version: current_version)
20
+
21
+ if !(response = get_package(normalize_package_name(package)))
22
+ outdated_package.error = 'Response error'
23
+ elsif (error = response['error'])
24
+ outdated_package.error = error
25
+ else
26
+ latest_version = find_latest_version(response)
27
+ next unless outdated?(current_version, latest_version)
28
+
29
+ outdated_package.latest_version = latest_version
30
+ end
31
+
32
+ outdated_packages << outdated_package
33
+ end.sort_by(&:name)
34
+ end
35
+
36
+ def vulnerable_packages
37
+ get_audit.flat_map do |package, vulnerabilities|
38
+ vulnerabilities.map do |vulnerability|
39
+ VulnerablePackage.new(name: package,
40
+ severity: vulnerability['severity'],
41
+ vulnerable_versions: vulnerability['vulnerable_versions'],
42
+ vulnerability: vulnerability['title'])
43
+ end
44
+ end.sort_by { |p| [p.name, p.severity] }
45
+ end
46
+
47
+ def packages_with_versions
48
+ # We cannot use the name after "pin" because some dependencies are loaded from inside packages
49
+ # Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
50
+
51
+ iconmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/)(.*)(?=@\d+\.\d+\.\d+)@(\d+\.\d+\.\d+(?:[^\/\s["']]*)).*$/) |
52
+ iconmap.scan(/^pin ["']([^["']]*)["'].* #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
53
+ end
54
+
55
+ private
56
+ OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
57
+ VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)
58
+
59
+ # Normalize the package name (remove any trailing paths)
60
+ def normalize_package_name(name)
61
+ if name.start_with?('@')
62
+ name.split('/', 3)[0..1].join('/')
63
+ else
64
+ name.split('/', 2).first
65
+ end
66
+ end
67
+
68
+ def iconmap
69
+ @iconmap ||= File.read(@iconmap_path)
70
+ end
71
+
72
+ def get_package(package)
73
+ uri = self.class.base_uri.dup
74
+ uri.path = "/" + package
75
+ response = get_json(uri)
76
+
77
+ JSON.parse(response)
78
+ rescue JSON::ParserError
79
+ nil
80
+ end
81
+
82
+ def get_json(uri)
83
+ request = Net::HTTP::Get.new(uri)
84
+ request["Content-Type"] = "application/json"
85
+
86
+ response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
87
+ http.request(request)
88
+ }
89
+
90
+ response.body
91
+ rescue => error
92
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
93
+ end
94
+
95
+ def find_latest_version(response)
96
+ latest_version = response.is_a?(String) ? response : response.dig('dist-tags', 'latest')
97
+ return latest_version if latest_version
98
+
99
+ return unless response['versions']
100
+
101
+ response['versions'].keys.map { |v| Gem::Version.new(v) rescue nil }.compact.sort.last
102
+ end
103
+
104
+ def outdated?(current_version, latest_version)
105
+ Gem::Version.new(current_version) < Gem::Version.new(latest_version)
106
+ rescue ArgumentError
107
+ current_version.to_s < latest_version.to_s
108
+ end
109
+
110
+ def get_audit
111
+ uri = self.class.base_uri.dup
112
+ uri.path = "/-/npm/v1/security/advisories/bulk"
113
+
114
+ body = packages_with_versions.each.with_object({}) { |(package, version), data|
115
+ data[package] ||= []
116
+ data[package] << version
117
+ }
118
+ return {} if body.empty?
119
+
120
+ response = post_json(uri, body)
121
+ JSON.parse(response.body)
122
+ end
123
+
124
+ def post_json(uri, body)
125
+ Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json")
126
+ rescue => error
127
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
128
+ end
129
+ end
@@ -0,0 +1,149 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+
5
+ class Iconmap::Packager
6
+ Error = Class.new(StandardError)
7
+ HTTPError = Class.new(Error)
8
+ ServiceError = Error.new(Error)
9
+
10
+ singleton_class.attr_accessor :endpoint
11
+ self.endpoint = URI("https://api.jspm.io/generate")
12
+
13
+ attr_reader :vendor_path
14
+
15
+ def initialize(iconmap_path = "config/iconmap.rb", vendor_path: "vendor/icons")
16
+ @iconmap_path = Pathname.new(iconmap_path)
17
+ @vendor_path = Pathname.new(vendor_path)
18
+ end
19
+
20
+ def import(*packages, env: "production", from: "jspm")
21
+ response = post_json({
22
+ "install" => Array(packages),
23
+ "flattenScope" => true,
24
+ "env" => [ "browser", "module", env ],
25
+ "provider" => normalize_provider(from)
26
+ })
27
+
28
+ case response.code
29
+ when "200" then extract_parsed_imports(response)
30
+ when "404", "401" then nil
31
+ else handle_failure_response(response)
32
+ end
33
+ end
34
+
35
+ def pin_for(package, url)
36
+ %(pin "#{package}", to: "#{url}")
37
+ end
38
+
39
+ def vendored_pin_for(package, url)
40
+ filename = package_filename(package)
41
+ version = extract_package_version_from(url)
42
+
43
+ if "#{package}" == filename
44
+ %(pin "#{package}" # #{version})
45
+ else
46
+ %(pin "#{package}", to: "#{filename}" # #{version})
47
+ end
48
+ end
49
+
50
+ def packaged?(package)
51
+ iconmap.match(/^pin ["']#{package}["'].*$/)
52
+ end
53
+
54
+ def download(package, url)
55
+ ensure_vendor_directory_exists
56
+ remove_existing_package_file(package)
57
+ download_package_file(package, url)
58
+ end
59
+
60
+ def remove(package)
61
+ remove_existing_package_file(package)
62
+ remove_package_from_iconmap(package)
63
+ end
64
+
65
+ private
66
+ def post_json(body)
67
+ Net::HTTP.post(self.class.endpoint, body.to_json, "Content-Type" => "application/json")
68
+ rescue => error
69
+ raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
70
+ end
71
+
72
+ def normalize_provider(name)
73
+ name.to_s == "jspm" ? "jspm.io" : name.to_s
74
+ end
75
+
76
+ def extract_parsed_imports(response)
77
+ JSON.parse(response.body).dig("map", "imports")
78
+ end
79
+
80
+ def handle_failure_response(response)
81
+ if error_message = parse_service_error(response)
82
+ raise ServiceError, error_message
83
+ else
84
+ raise HTTPError, "Unexpected response code (#{response.code})"
85
+ end
86
+ end
87
+
88
+ def parse_service_error(response)
89
+ JSON.parse(response.body.to_s)["error"]
90
+ rescue JSON::ParserError
91
+ nil
92
+ end
93
+
94
+ def iconmap
95
+ @iconmap ||= File.read(@iconmap_path)
96
+ end
97
+
98
+
99
+ def ensure_vendor_directory_exists
100
+ FileUtils.mkdir_p @vendor_path
101
+ end
102
+
103
+ def remove_existing_package_file(package)
104
+ FileUtils.rm_rf vendored_package_path(package)
105
+ end
106
+
107
+ def remove_package_from_iconmap(package)
108
+ all_lines = File.readlines(@iconmap_path)
109
+ with_lines_removed = all_lines.grep_v(/pin ["']#{package}["']/)
110
+
111
+ File.open(@iconmap_path, "w") do |file|
112
+ with_lines_removed.each { |line| file.write(line) }
113
+ end
114
+ end
115
+
116
+ def download_package_file(package, url)
117
+ response = Net::HTTP.get_response(URI(url))
118
+
119
+ if response.code == "200"
120
+ save_vendored_package(package, url, response.body)
121
+ else
122
+ handle_failure_response(response)
123
+ end
124
+ end
125
+
126
+ def save_vendored_package(package, url, source)
127
+ File.open(vendored_package_path(package), "w+") do |vendored_package|
128
+ vendored_package.write "<!-- #{package}#{extract_package_version_from(url)} downloaded from #{url} -->\n\n"
129
+
130
+ vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8")
131
+ end
132
+ end
133
+
134
+ def remove_sourcemap_comment_from(source)
135
+ source.gsub(/^\/\/# sourceMappingURL=.*/, "")
136
+ end
137
+
138
+ def vendored_package_path(package)
139
+ @vendor_path.join(package_filename(package))
140
+ end
141
+
142
+ def package_filename(package)
143
+ package.gsub("/", "--")
144
+ end
145
+
146
+ def extract_package_version_from(url)
147
+ url.match(/@\d+\.\d+\.\d+/)&.to_a&.first
148
+ end
149
+ end
@@ -0,0 +1,23 @@
1
+ require "active_support"
2
+ require "active_support/core_ext/module/delegation"
3
+
4
+ class Iconmap::Reloader
5
+ delegate :execute_if_updated, :execute, :updated?, to: :updater
6
+
7
+ def reload!
8
+ icon_map_paths.each { |path| Rails.application.iconmap.draw(path) }
9
+ end
10
+
11
+ private
12
+ def updater
13
+ @updater ||= config.file_watcher.new(icon_map_paths) { reload! }
14
+ end
15
+
16
+ def icon_map_paths
17
+ config.iconmap.paths
18
+ end
19
+
20
+ def config
21
+ Rails.application.config
22
+ end
23
+ end
@@ -0,0 +1,3 @@
1
+ module Iconmap
2
+ VERSION = "0.0.1"
3
+ end
@@ -0,0 +1,6 @@
1
+ module Iconmap
2
+ end
3
+
4
+ require_relative "iconmap/version"
5
+ require_relative "iconmap/reloader"
6
+ require_relative "iconmap/engine" if defined?(Rails::Railtie)
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../config/application"
4
+ require "iconmap/commands"
@@ -0,0 +1,2 @@
1
+ # Pin icons by running ./bin/iconmap
2
+
@@ -0,0 +1,16 @@
1
+ say "Use vendor/icons for downloaded pins"
2
+ empty_directory "vendor/icons"
3
+ keep_file "vendor/icons"
4
+
5
+ if (sprockets_manifest_path = Rails.root.join("app/assets/config/manifest.js")).exist?
6
+ say "Ensure icons are in the Sprocket manifest"
7
+ append_to_file sprockets_manifest_path,
8
+ %(//= link_tree ../../../vendor/icons .svg\n)
9
+ end
10
+
11
+ say "Configure iconmap paths in config/iconmap.rb"
12
+ copy_file "#{__dir__}/config/iconmap.rb", "config/iconmap.rb"
13
+
14
+ say "Copying binstub"
15
+ copy_file "#{__dir__}/bin/iconmap", "bin/iconmap"
16
+ chmod "bin", 0755 & ~File.umask, verbose: false
@@ -0,0 +1,9 @@
1
+ namespace :iconmap do
2
+ desc "Setup Iconmap for the app"
3
+ task :install do
4
+ previous_location = ENV["LOCATION"]
5
+ ENV["LOCATION"] = File.expand_path("../install/install.rb", __dir__)
6
+ Rake::Task["app:template"].invoke
7
+ ENV["LOCATION"] = previous_location
8
+ end
9
+ end
metadata ADDED
@@ -0,0 +1,101 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: iconmap-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Geremia Taglialatela
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2025-02-04 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: 7.1.0
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 7.1.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: 7.1.0
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: 7.1.0
40
+ - !ruby/object:Gem::Dependency
41
+ name: actionpack
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 7.1.0
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 7.1.0
54
+ email: tagliala.dev@gmail.com
55
+ executables: []
56
+ extensions: []
57
+ extra_rdoc_files: []
58
+ files:
59
+ - MIT-LICENSE
60
+ - README.md
61
+ - Rakefile
62
+ - app/controllers/iconmap/freshness.rb
63
+ - app/helpers/iconmap/iconmap_tags_helper.rb
64
+ - lib/iconmap-rails.rb
65
+ - lib/iconmap/commands.rb
66
+ - lib/iconmap/engine.rb
67
+ - lib/iconmap/map.rb
68
+ - lib/iconmap/npm.rb
69
+ - lib/iconmap/packager.rb
70
+ - lib/iconmap/reloader.rb
71
+ - lib/iconmap/version.rb
72
+ - lib/install/bin/iconmap
73
+ - lib/install/config/iconmap.rb
74
+ - lib/install/install.rb
75
+ - lib/tasks/iconmap_tasks.rake
76
+ homepage: https://github.com/tagliala/iconmap-rails
77
+ licenses:
78
+ - MIT
79
+ metadata:
80
+ homepage_uri: https://github.com/tagliala/iconmap-rails
81
+ source_code_uri: https://github.com/tagliala/iconmap-rails
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.1.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.3
98
+ specification_version: 4
99
+ summary: Use ESM with importmap to manage modern JavaScript in Rails without transpiling
100
+ or bundling.
101
+ test_files: []