jspm-rails 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: d28f515c7c82e31da220cc7dca25540b499ad2f3ca9f98f0b9e9092dc31940a6
4
+ data.tar.gz: 2fa31fa61006a069f3d8876ce5c68e60e0e766852749ff062656a1436e67f7e6
5
+ SHA512:
6
+ metadata.gz: 1dc7843f5631aa615fb7797120c6d047c74808ff08d6ec429db85b3ef1efff1a61419a35f5dd10c644df650cfd9484ea20a455ecd8ef25151714eb959678e2fa
7
+ data.tar.gz: 2074d972cf1e2a2fb11a8753df72228a5cb1984b43455c906e23c176d3bb8d5a110e846c43ebffe4c267544e5faedc16fab871050e9bc9fe70197bb1eee16a3b
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - `bin/rails jspm:install` traces `app/javascript/application.js` with jspm and vendors every module it reaches. It copies npm dependencies' stylesheets, fonts and images. `config/importmap.json` locks versions, and `package-lock.json` overrides it for packages installed with npm.
6
+ - `javascript_jspm_tags` renders the import map with Propshaft's digested paths, preloads, and the entry module.
7
+ - Modules on Propshaft's load path, including engines', are importable by asset name, the way importmap-rails pins work.
8
+ - `bin/rails jspm:check` fails when `config/importmap.json` or `vendor/jspm` is out of date.
9
+ - Warns when the import map can't match `package-lock.json`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Clay Shentrup
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,260 @@
1
+ ```
2
+ _ _ _
3
+ (_) ___ _ __ _ __ _ _ __ _ (_)| | ___
4
+ | |(_-<| '_ \| ' \ ___ | '_|/ _` || || |(_-<
5
+ _/ |/__/| .__/|_|_|_||___||_| \__,_||_||_|/__/
6
+ |__/ |_|
7
+ ```
8
+
9
+ [![CI](https://github.com/ClayShentrup/jspm-rails/actions/workflows/ci.yml/badge.svg)](https://github.com/ClayShentrup/jspm-rails/actions/workflows/ci.yml)
10
+
11
+ **Import maps for Rails, built by [jspm](https://jspm.org).**
12
+
13
+ Your JavaScript ships as the ES modules its authors wrote. There's no bundler, no build step and no Node in production, and the browser caches each module on its own. Use jspm alone, or install packages with npm too. npm then sets their versions, so Dependabot keeps working.
14
+
15
+ ```sh
16
+ bin/rails jspm:install
17
+ ```
18
+
19
+ ```
20
+ traced ./app/javascript/application.js
21
+ vendored 31 modules from ga.jspm.io
22
+ copied 73 assets from node_modules
23
+ wrote config/importmap.json
24
+ ```
25
+
26
+ ## Contents
27
+
28
+ - [Why](#why)
29
+ - [Quick start](#quick-start)
30
+ - [Writing your JavaScript](#writing-your-javascript)
31
+ - [Versions: plain jspm, or npm too](#versions-plain-jspm-or-npm-too)
32
+ - [Stylesheets, fonts and images](#stylesheets-fonts-and-images)
33
+ - [Keeping up to date](#keeping-up-to-date)
34
+ - [Warnings](#warnings)
35
+ - [Deploying](#deploying)
36
+ - [How it works](#how-it-works)
37
+ - [Compared to](#compared-to)
38
+ - [FAQ](#faq)
39
+ - [Troubleshooting](#troubleshooting)
40
+ - [Development](#development)
41
+
42
+ ## Why
43
+
44
+ Rails 8 ships import maps by default, through [importmap-rails](https://github.com/rails/importmap-rails). It vendors one file per package. That works until a package imports its own internal files or depends on other packages. Then you pin the whole tree by hand, and `bin/importmap update` can't tell which versions belong together. A package spread across many modules ends up split across versions, or missing files.
45
+
46
+ jspm solves that. It traces your code and finds every module your code reaches, across whole dependency trees. jspm-rails runs that trace, vendors what it finds, and hands the result to Propshaft. So you get import maps that work for any package on npm, with versions locked the way you'd expect.
47
+
48
+ ## Quick start
49
+
50
+ You need Rails 8 with Propshaft, and Node with npm on your development machine.
51
+
52
+ ```sh
53
+ bundle add jspm-rails
54
+ npm install --save-dev @jspm/generator
55
+ ```
56
+
57
+ Render the tags in your layout's `<head>`:
58
+
59
+ ```erb
60
+ <%= javascript_jspm_tags %>
61
+ ```
62
+
63
+ Write `app/javascript/application.js`:
64
+
65
+ ```js
66
+ import confetti from 'canvas-confetti'
67
+
68
+ confetti()
69
+ ```
70
+
71
+ Install, and commit what it writes:
72
+
73
+ ```sh
74
+ bin/rails jspm:install
75
+ git add config/importmap.json vendor/jspm
76
+ ```
77
+
78
+ Reload the page.
79
+
80
+ ## Writing your JavaScript
81
+
82
+ **The entry point is `app/javascript/application.js`.** `javascript_jspm_tags` loads it as a module, and the install traces everything it imports.
83
+
84
+ **Import packages by name,** as their documentation shows:
85
+
86
+ ```js
87
+ import TomSelect from 'tom-select'
88
+ import { Controller } from '@hotwired/stimulus'
89
+ ```
90
+
91
+ **Import your own modules by asset name,** as importmap-rails pins them: the logical path without its extension, and a directory's name for its `index.js`. Relative paths work too:
92
+
93
+ ```js
94
+ import { setUpForms } from 'forms' // app/javascript/forms.js
95
+ import 'controllers' // app/javascript/controllers/index.js
96
+ import { helper } from './helpers.js'
97
+ ```
98
+
99
+ **Import modules that gems ship the same way.** Every engine's `app/assets` directories are on Propshaft's load path, so a gem's modules are importable by asset name with no setup:
100
+
101
+ ```js
102
+ import MathController from 'astrology/math_controller'
103
+ ```
104
+
105
+ **Import rarely used code dynamically.** The install vendors it too, but the page doesn't preload it, so the browser fetches it only when it runs:
106
+
107
+ ```js
108
+ const { default: katex } = await import('katex')
109
+ ```
110
+
111
+ ## Versions: plain jspm, or npm too
112
+
113
+ **Plain jspm works on its own.** The install resolves each package you import from jspm.io. That's the latest version, or the newest within a range you give in `package.json`'s `dependencies`, as jspm does. `config/importmap.json` is jspm's lockfile: every install keeps the versions it already holds.
114
+
115
+ **Install a package with npm when you want more.** Its version in `package-lock.json` then overrides the import map, and you get:
116
+
117
+ - Dependabot pull requests for it.
118
+ - Its stylesheets, fonts and images in `node_modules`, at the same version as its JavaScript.
119
+
120
+ Mix the two freely. A package's JavaScript always comes from jspm.io either way. npm decides the version, not the files.
121
+
122
+ ## Stylesheets, fonts and images
123
+
124
+ jspm.io serves JavaScript. For a package's other files, install it with npm as a dependency. The install copies the stylesheets, fonts and images of every package in `dependencies`, and their dependencies, into `vendor/jspm/<package>/`:
125
+
126
+ ```erb
127
+ <%= stylesheet_link_tag 'katex/dist/katex' %>
128
+ ```
129
+
130
+ Propshaft rewrites the `url()`s in the stylesheet to digested paths, so the fonts load too. The CSS comes from npm and the JavaScript from jspm.io, both at the version in `package-lock.json`.
131
+
132
+ Each name starts with its package, so two packages' `style.css` never collide. `devDependencies` are build tools, so the install leaves them out.
133
+
134
+ Why copy, instead of putting `node_modules` on the load path? Propshaft checks every file on its load path for changes before each request in development. With one real app's `node_modules` (8,900 files), that took a page request from 1.4 ms to 98 ms. Production would also precompile all of `node_modules` into `public/assets`.
135
+
136
+ ## Keeping up to date
137
+
138
+ **npm packages:** when Dependabot, or you, changes `package-lock.json`, run `bin/rails jspm:install` again.
139
+
140
+ **Plain jspm packages:** delete the package's entries from `config/importmap.json`, then run the install. jspm resolves it afresh.
141
+
142
+ **To keep the map from drifting,** run the check in CI:
143
+
144
+ ```sh
145
+ bin/rails jspm:check
146
+ ```
147
+
148
+ It traces again without writing anything. It fails when an install would change `config/importmap.json`, for example after Dependabot bumps `package-lock.json`. It also fails when a module is missing from `vendor/jspm`, or a copied stylesheet, font or image no longer matches `node_modules`:
149
+
150
+ ```
151
+ stale config/importmap.json
152
+ run bin/rails jspm:install
153
+ ```
154
+
155
+ ## Warnings
156
+
157
+ The install warns when the import map can't match `package-lock.json`:
158
+
159
+ ```
160
+ warn @orchidjs/unicode-variants: package-lock.json has 1.1.1 and 1.2.2, and the import map uses 1.1.1 everywhere
161
+ ```
162
+
163
+ npm installs two versions of a package when its dependents' ranges don't overlap. An import map gives each package one version per scope, so jspm-rails uses the top-level version everywhere and tells you. Check that the packages needing the other version still work, or upgrade them so the ranges meet.
164
+
165
+ ## Deploying
166
+
167
+ - **Commit `config/importmap.json` and `vendor/jspm`.** `assets:precompile` digests the vendored modules like any other asset.
168
+ - **Production needs no Node, no npm and no jspm.io.**
169
+ - **An `asset_host` on another origin must send `Access-Control-Allow-Origin`.** Browsers load modules and fonts across origins only with CORS.
170
+ - **Content Security Policy works.** Every tag carries the request's nonce.
171
+ - **Turbo reloads the page when the import map changes,** because the map's tag has `data-turbo-track="reload"`.
172
+
173
+ ## How it works
174
+
175
+ `bin/rails jspm:install`:
176
+
177
+ 1. Boots the app, then traces `app/javascript/application.js` with [`@jspm/generator`](https://github.com/jspm/generator). Every module on Propshaft's load path is importable by asset name. The previous `config/importmap.json` locks versions, and `package-lock.json` pins every package npm installed. Only packages your code reaches end up in the map, so KaTeX's command-line dependency stays out.
178
+ 2. Downloads each traced module from jspm.io, with its source map, into `vendor/jspm/ga.jspm.io`.
179
+ 3. Copies each npm dependency's stylesheets, fonts and images into `vendor/jspm/<package>/`.
180
+ 4. Writes jspm's import map to `config/importmap.json`, with the list of modules to preload. Local modules appear as `asset:` plus their logical path, such as `asset:astrology/math_controller.js`, so the map is the same on every machine.
181
+
182
+ `javascript_jspm_tags` renders that map for Propshaft. Propshaft serves each file under a digested name, so the helper rewrites the map's URLs:
183
+
184
+ ```json
185
+ "tom-select": "https://ga.jspm.io/npm:tom-select@2.4.3/dist/esm/tom-select.complete.js"
186
+ ```
187
+
188
+ becomes
189
+
190
+ ```json
191
+ "tom-select": "/assets/ga.jspm.io/npm/tom-select@2.4.3/dist/esm/tom-select.complete-c479c3ee.js"
192
+ ```
193
+
194
+ A module imports its siblings by their plain names, though. When `tom-select.complete.js` imports `./constants.js`, the browser asks for `/assets/.../constants.js`, which Propshaft doesn't serve. So the rendered map also sends each plain module URL to its digested one:
195
+
196
+ ```json
197
+ "/assets/ga.jspm.io/npm/tom-select@2.4.3/dist/esm/constants.js":
198
+ "/assets/ga.jspm.io/npm/tom-select@2.4.3/dist/esm/constants-115aef3a.js"
199
+ ```
200
+
201
+ That's a standard import-map feature, so nothing gets rewritten inside the files.
202
+
203
+ ## Compared to
204
+
205
+ - **importmap-rails** pins one file per package, and you resolve the dependency tree by hand. jspm-rails traces the whole tree.
206
+ - **jsbundling-rails** resolves the tree too, but compiles your JavaScript into a bundle. That means a watcher in development and a build in CI and deploys. And a change to any module busts the cache for the whole bundle. jspm-rails ships the modules as written.
207
+
208
+ ## FAQ
209
+
210
+ **Why vendor, instead of loading from jspm.io?**
211
+ For the reasons importmap-rails switched to vendoring in [rails/importmap-rails#217](https://github.com/rails/importmap-rails/pull/217). Under HTTP/2, every extra origin costs a connection, so serving from your own costs less. Your app also stops depending on a third party at runtime, and development works offline.
212
+
213
+ **Do I need npm at all?**
214
+ You need it to install `@jspm/generator`. Beyond that, no: plain jspm packages need nothing in `package.json`.
215
+
216
+ **Can I use TypeScript or JSX?**
217
+ Not with jspm-rails, which doesn't transpile. Use jsbundling-rails.
218
+
219
+ **Can I import a stylesheet as a module (`with { type: 'css' }`)?**
220
+ Safari doesn't support CSS module scripts yet, so link stylesheets with `stylesheet_link_tag`.
221
+
222
+ **Can I have more than one entry point?**
223
+ Not yet. Import everything from `application.js`, dynamically for code only some pages need.
224
+
225
+ **Can I use it alongside importmap-rails?**
226
+ Use one or the other. Both render the page's import map.
227
+
228
+ **Does it work on Windows?**
229
+ It's untested there.
230
+
231
+ ## Troubleshooting
232
+
233
+ **`tracing ./app/javascript/application.js failed`**
234
+ Node printed the reason just above. Usually Node isn't on your `PATH`, or `@jspm/generator` isn't installed: run `npm install --save-dev @jspm/generator`.
235
+
236
+ **`Propshaft::MissingAssetError` for a file under `ga.jspm.io`**
237
+ `config/importmap.json` names a module that `vendor/jspm` doesn't have. Run `bin/rails jspm:install` and commit both.
238
+
239
+ **Modules or fonts fail to load with an asset host**
240
+ The asset host must send `Access-Control-Allow-Origin`. See [Deploying](#deploying).
241
+
242
+ **A package's stylesheet 404s**
243
+ Install the package with npm as a dependency, not a devDependency, and run the install. See [Stylesheets, fonts and images](#stylesheets-fonts-and-images).
244
+
245
+ ## Development
246
+
247
+ ```sh
248
+ bundle install
249
+ npm ci && npx playwright install chromium
250
+ (cd spec/dummy && npm ci) && (cd spec/fixtures/conflicted && npm ci)
251
+ bundle exec rake
252
+ ```
253
+
254
+ The specs install into a dummy Rails app through `bin/rails jspm:install`. Then they drive it in Chromium through Playwright, on one origin and on two, finding elements by their accessible role and name. Any JavaScript error fails the example. So they need network access to jspm.io.
255
+
256
+ Report security issues privately; see [SECURITY.md](SECURITY.md).
257
+
258
+ ## License
259
+
260
+ [MIT](LICENSE)
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jspm
4
+ # The stylesheets, fonts and images of each npm dependency, and where each
5
+ # one is vendored. devDependencies are build tools, so they stay out.
6
+ module Assets
7
+ EXTENSIONS = %w[
8
+ .avif
9
+ .css
10
+ .eot
11
+ .gif
12
+ .jpeg
13
+ .jpg
14
+ .otf
15
+ .png
16
+ .svg
17
+ .ttf
18
+ .webp
19
+ .woff
20
+ .woff2
21
+ ].freeze
22
+ private_constant :EXTENSIONS
23
+
24
+ class << self
25
+ # Each copy is a pair: the file in node_modules, and its vendored path.
26
+ def copies(lockfile:, root:)
27
+ lockfile.browser_packages.flat_map { |package| package_copies(package, root) }
28
+ end
29
+
30
+ private
31
+
32
+ def package_copies(package, root)
33
+ package_dir = root.join(package.path)
34
+ vendor_dir = root.join(Paths::VENDOR, package.name)
35
+ files =
36
+ package_dir.glob('**/*').select do |file|
37
+ EXTENSIONS.include?(file.extname.downcase)
38
+ end
39
+ files.map do |file|
40
+ relative_path = file.relative_path_from(package_dir)
41
+ [file, vendor_dir.join(relative_path)]
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
data/lib/jspm/check.rb ADDED
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+
6
+ module Jspm
7
+ # Traces again without writing anything, and reports whether
8
+ # config/importmap.json and vendor/jspm match what an install would produce.
9
+ class Check
10
+ def self.call(asset_paths:, output:, root:)
11
+ lockfile = Lockfile.read(root)
12
+ trace = Trace.call(asset_paths:, lockfile:, root:)
13
+ new(lockfile:, output:, root:, trace:).call
14
+ end
15
+
16
+ def initialize(lockfile:, output:, root:, trace:)
17
+ @lockfile = lockfile
18
+ @output = output
19
+ @root = root
20
+ @trace = trace
21
+ end
22
+
23
+ def call
24
+ written = written?
25
+ missing = missing_modules
26
+ stale_assets = stale_asset_paths
27
+ report('stale', Paths::IMPORT_MAP) unless written
28
+ missing.each { |path| report('missing', path) }
29
+ stale_assets.each { |path| report('stale', path) }
30
+
31
+ up_to_date = written && missing.empty? && stale_assets.empty?
32
+ if up_to_date
33
+ report('ok', 'nothing to install')
34
+ else
35
+ report('run', 'bin/rails jspm:install')
36
+ end
37
+ up_to_date
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :lockfile, :output, :root, :trace
43
+
44
+ def written?
45
+ import_map_file = root.join(Paths::IMPORT_MAP)
46
+ import_map_file.exist? && JSON.parse(import_map_file.read) == trace.import_map
47
+ end
48
+
49
+ def stale_asset_paths
50
+ copies = Assets.copies(lockfile:, root:)
51
+ stale =
52
+ copies.reject do |source, target|
53
+ target.file? && FileUtils.compare_file(source, target)
54
+ end
55
+ stale.map { |_source, target| target.relative_path_from(root).to_s }
56
+ end
57
+
58
+ def missing_modules
59
+ vendored = trace.cdn_urls.map { |url| File.join(Paths::VENDOR, Paths.logical(url)) }
60
+ vendored.reject { |path| root.join(path).file? }
61
+ end
62
+
63
+ def report(status, message)
64
+ output.puts(format('%<status>12s %<message>s', message:, status:))
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jspm
4
+ # View helpers for apps whose JavaScript jspm-rails installs.
5
+ module Helper
6
+ def javascript_jspm_tags
7
+ Tags.call(view: self)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'json'
5
+ require 'net/http'
6
+
7
+ module Jspm
8
+ # Vendors every module the trace reaches from jspm.io, copies npm
9
+ # dependencies' stylesheets, fonts and images, and writes
10
+ # config/importmap.json.
11
+ class Install
12
+ SOURCE_MAP = %r{//# sourceMappingURL=(?<path>\S+\.map)\s*\z}
13
+ PACKAGE_URL = %r{\Ahttps://ga\.jspm\.io/npm:(?<name>(?:@[^/]+/)?[^@/]+)@(?<version>[^/]+)/}
14
+ private_constant :PACKAGE_URL, :SOURCE_MAP
15
+
16
+ def self.call(asset_paths:, output:, root:)
17
+ lockfile = Lockfile.read(root)
18
+ trace = Trace.call(asset_paths:, lockfile:, root:)
19
+ new(lockfile:, output:, root:, trace:).call
20
+ end
21
+
22
+ def initialize(lockfile:, output:, root:, trace:)
23
+ @lockfile = lockfile
24
+ @output = output
25
+ @root = root
26
+ @trace = trace
27
+ end
28
+
29
+ def call
30
+ vendor_dir = root.join(Paths::VENDOR)
31
+ FileUtils.rm_rf(vendor_dir)
32
+ cdn = URI(Paths::CDN)
33
+ cdn_urls = trace.cdn_urls
34
+ Net::HTTP.start(cdn.host, cdn.port, use_ssl: true) { |http| download_all(http, cdn_urls, vendor_dir) }
35
+ copies = Assets.copies(lockfile:, root:)
36
+ copies.each do |source, target|
37
+ target.dirname.mkpath
38
+ FileUtils.cp(source, target)
39
+ end
40
+ root.join(Paths::IMPORT_MAP).write("#{JSON.pretty_generate(trace.import_map)}\n")
41
+
42
+ report('traced', Paths::ENTRY_PATH)
43
+ report('vendored', "#{cdn_urls.size} modules from ga.jspm.io")
44
+ report('copied', "#{copies.size} assets from node_modules")
45
+ report('wrote', Paths::IMPORT_MAP)
46
+ warn_about_versions
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :lockfile, :output, :root, :trace
52
+
53
+ def download_all(http, urls, vendor_dir)
54
+ urls.each { |url| download(http, url, vendor_dir) }
55
+ end
56
+
57
+ def download(http, url, vendor_dir)
58
+ body = http.get(URI(url).path).tap(&:value).body
59
+ file = vendor_dir.join(Paths.logical(url))
60
+ file.dirname.mkpath
61
+ file.binwrite(body)
62
+ source_maps = body.scan(SOURCE_MAP).map { |(path)| URI.join(url, path).to_s }
63
+ download_all(http, source_maps, vendor_dir)
64
+ end
65
+
66
+ def warn_about_versions
67
+ packages =
68
+ trace.urls.grep(PACKAGE_URL) do |url|
69
+ url.match(PACKAGE_URL).values_at(:name, :version)
70
+ end
71
+ traced = packages.sort.to_h
72
+ versions_by_name =
73
+ traced.to_h { |name, version| [name, (lockfile.nested_versions(name) | [version]).sort] }
74
+ conflicts = versions_by_name.select { |_name, versions| versions.many? }
75
+ conflicts.each do |name, versions|
76
+ report(
77
+ 'warn',
78
+ "#{name}: package-lock.json has #{versions.join(' and ')}, " \
79
+ "and the import map uses #{traced.fetch(name)} everywhere"
80
+ )
81
+ end
82
+ end
83
+
84
+ def report(status, message)
85
+ output.puts(format('%<status>12s %<message>s', message:, status:))
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jspm
4
+ # The modules Propshaft serves from the app and from engines. Each one is
5
+ # importable by its asset name without the extension, the way importmap-rails
6
+ # pins work: 'controllers/hello_controller', or 'controllers' for
7
+ # controllers/index.js.
8
+ #
9
+ # config/importmap.json names these files as `asset:` plus their logical
10
+ # path, so the map reads the same on every machine.
11
+ class LocalModules
12
+ EXTENSIONS = %w[.js .mjs].freeze
13
+ SCHEME = 'asset:'
14
+ private_constant :EXTENSIONS, :SCHEME
15
+
16
+ LocalModule =
17
+ Data.define(:logical_path, :path) do
18
+ def asset_name
19
+ "#{SCHEME}#{logical_path}"
20
+ end
21
+
22
+ def pins
23
+ name = logical_path.delete_suffix(File.extname(logical_path))
24
+ index = name.delete_suffix('/index') if name.end_with?('/index')
25
+ [name, *index].map { |pin| [pin, path] }
26
+ end
27
+ end
28
+ private_constant :LocalModule
29
+
30
+ class << self
31
+ def find(asset_paths:)
32
+ new(modules: asset_paths.flat_map { |directory| directory_modules(directory) })
33
+ end
34
+
35
+ private
36
+
37
+ def directory_modules(directory)
38
+ files = directory.glob('**/*').select { |file| EXTENSIONS.include?(file.extname) }
39
+ files.map do |file|
40
+ logical_path = file.relative_path_from(directory).to_s
41
+ LocalModule.new(logical_path:, path: file.to_s)
42
+ end
43
+ end
44
+ end
45
+
46
+ def initialize(modules:)
47
+ @modules = modules
48
+ end
49
+
50
+ def pins
51
+ modules.flat_map(&:pins).to_h
52
+ end
53
+
54
+ # A local file becomes its asset name. A local directory, the key of a
55
+ # scope, becomes the asset root, where every local module is served. A
56
+ # jspm.io URL stays as it is.
57
+ def localize(target)
58
+ case target
59
+ when %r{\A/.*/\z} then SCHEME
60
+ when %r{\A/} then modules_by_path.fetch(target).asset_name
61
+ else target
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ attr_reader :modules
68
+
69
+ def modules_by_path
70
+ modules.to_h { |local_module| [local_module.path, local_module] }
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Jspm
6
+ # The packages npm installed, as package-lock.json records them.
7
+ class Lockfile
8
+ Package =
9
+ Data.define(:dev, :name, :path, :top_level, :version) do
10
+ def browser?
11
+ top_level && !dev
12
+ end
13
+
14
+ def resolution
15
+ [name, version]
16
+ end
17
+
18
+ def nested?(other_name)
19
+ !top_level && name == other_name
20
+ end
21
+ end
22
+ private_constant :Package
23
+
24
+ def self.read(root)
25
+ json = JSON.parse(root.join('package-lock.json').read)
26
+ installed = json.fetch('packages').reject { |path, _package| path.empty? }
27
+ packages =
28
+ installed.map do |path, package|
29
+ name = path.split('node_modules/').last
30
+ top_level = path.scan('node_modules/').one?
31
+ version = package.fetch('version')
32
+ dev = package.fetch('dev', false)
33
+ Package.new(dev:, name:, path:, top_level:, version:)
34
+ end
35
+ new(packages:)
36
+ end
37
+
38
+ def initialize(packages:)
39
+ @packages = packages
40
+ end
41
+
42
+ def resolutions
43
+ packages.select(&:top_level).to_h(&:resolution)
44
+ end
45
+
46
+ def browser_packages
47
+ packages.select(&:browser?)
48
+ end
49
+
50
+ def nested_versions(name)
51
+ nested = packages.select { |package| package.nested?(name) }
52
+ nested.map(&:version)
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :packages
58
+ end
59
+ end
data/lib/jspm/paths.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jspm
4
+ # Translates the URLs in config/importmap.json into Propshaft logical paths.
5
+ #
6
+ # jspm.io files are vendored under vendor/jspm/ga.jspm.io/npm/, and local
7
+ # modules are written as `asset:` plus their logical path.
8
+ module Paths
9
+ CDN = 'https://ga.jspm.io/'
10
+ ENTRY = 'application.js'
11
+ LOCAL = './app/javascript/'
12
+ ENTRY_PATH = "#{LOCAL}#{ENTRY}".freeze
13
+ IMPORT_MAP = 'config/importmap.json'
14
+ VENDOR = 'vendor/jspm'
15
+ public_constant :CDN, :ENTRY, :ENTRY_PATH, :IMPORT_MAP, :LOCAL, :VENDOR
16
+
17
+ def self.logical(url)
18
+ return url.delete_prefix('https://').sub('/npm:', '/npm/') if url.start_with?(CDN)
19
+
20
+ url.delete_prefix('asset:')
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module Jspm
6
+ # Serves the app's modules and the vendored dependencies through Propshaft.
7
+ class Railtie < Rails::Railtie
8
+ # jspm.io publishes many packages' modules as .mjs, and browsers only run
9
+ # a module script served with a JavaScript MIME type.
10
+ initializer 'jspm.mime_type' do
11
+ Mime::Type.register_alias('text/javascript', :mjs)
12
+ end
13
+
14
+ initializer 'jspm.assets' do |app|
15
+ app.config.assets.paths.push(app.root.join('app/javascript'), app.root.join('vendor/jspm'))
16
+ end
17
+
18
+ initializer 'jspm.helper' do
19
+ ActiveSupport.on_load(:action_view) { include Helper }
20
+ end
21
+
22
+ rake_tasks do
23
+ load File.expand_path('../tasks/jspm.rake', __dir__)
24
+ end
25
+ end
26
+ end
data/lib/jspm/tags.rb ADDED
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Jspm
6
+ # Renders config/importmap.json for the browser.
7
+ #
8
+ # Propshaft serves every file under a digested name, but a module's relative
9
+ # imports name its siblings undigested. So the rendered map also sends each
10
+ # undigested module URL to its digested one.
11
+ class Tags
12
+ MODULE_EXTENSIONS = %w[.css .js .mjs].freeze
13
+ private_constant :MODULE_EXTENSIONS
14
+
15
+ def self.call(view:)
16
+ import_map = JSON.parse(Rails.root.join(Paths::IMPORT_MAP).read)
17
+ load_path = Rails.application.assets.load_path
18
+ entry_url = view.asset_path(Paths::ENTRY)
19
+ digested_entry = load_path.find(Paths::ENTRY).digested_path.to_s
20
+ prefix = entry_url.delete_suffix(digested_entry)
21
+ new(entry_url:, import_map:, load_path:, prefix:, view:).call
22
+ end
23
+
24
+ def initialize(entry_url:, import_map:, load_path:, prefix:, view:)
25
+ @entry_url = entry_url
26
+ @import_map = import_map
27
+ @load_path = load_path
28
+ @prefix = prefix
29
+ @view = view
30
+ end
31
+
32
+ def call
33
+ nonce = view.content_security_policy_nonce
34
+ tag = view.tag
35
+ imports = asset_urls(import_map.fetch('imports'))
36
+ json = ERB::Util.json_escape(JSON.generate(imports: imports.merge(digests), scopes:))
37
+ preloads =
38
+ import_map.fetch('preload').map do |url|
39
+ href = asset_url(url)
40
+ tag.link(href:, nonce:, rel: 'modulepreload')
41
+ end
42
+ import_map_tag = tag.script(
43
+ json.html_safe,
44
+ data: { turbo_track: 'reload' },
45
+ nonce:,
46
+ type: 'importmap'
47
+ )
48
+ entry_tag = tag.script(nonce:, src: entry_url, type: 'module')
49
+ view.safe_join([import_map_tag, *preloads, entry_tag], "\n")
50
+ end
51
+
52
+ private
53
+
54
+ attr_reader :entry_url, :import_map, :load_path, :prefix, :view
55
+
56
+ def scopes
57
+ import_map.fetch('scopes', {}).to_h do |scope, specifiers|
58
+ ["#{prefix}#{Paths.logical(scope)}", asset_urls(specifiers)]
59
+ end
60
+ end
61
+
62
+ def asset_urls(specifiers)
63
+ specifiers.transform_values { |url| asset_url(url) }
64
+ end
65
+
66
+ def digests
67
+ paths = load_path.assets.map { |asset| [asset.logical_path, asset.digested_path] }
68
+ modules =
69
+ paths.select do |logical_path, _digested_path|
70
+ MODULE_EXTENSIONS.include?(logical_path.extname)
71
+ end
72
+ modules.to_h do |logical_path, digested_path|
73
+ ["#{prefix}#{logical_path}", "#{prefix}#{digested_path}"]
74
+ end
75
+ end
76
+
77
+ def asset_url(url)
78
+ logical_path = Paths.logical(url)
79
+ view.asset_path(logical_path)
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,50 @@
1
+ // Traces the app's entry module with @jspm/generator and prints the result as
2
+ // JSON. The generator is resolved from the app's own node_modules, so the app
3
+ // pins its version in package-lock.json like any other dependency.
4
+ //
5
+ // Ruby speaks file paths and jspm speaks URLs, so local files cross this
6
+ // boundary as absolute paths.
7
+ import { readFileSync } from 'node:fs';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+
11
+ const { entry, inputMap, resolutions, root } = JSON.parse(readFileSync(0, 'utf8'));
12
+ const rootUrl = pathToFileURL(`${root}/`);
13
+ const generatorPath = createRequire(rootUrl).resolve('@jspm/generator');
14
+ const { Generator } = await import(pathToFileURL(generatorPath));
15
+
16
+ const toUrl = (target) => (target.startsWith('/') ? pathToFileURL(target).href : target);
17
+ const toPath = (target) => {
18
+ const url = new URL(target, rootUrl);
19
+ return url.protocol === 'file:' ? fileURLToPath(url) : url.href;
20
+ };
21
+ const mapEntries = (specifiers, convert) =>
22
+ Object.fromEntries(Object.entries(specifiers).map(([name, target]) => [name, convert(target)]));
23
+ const mapScopes = (scopes, convert) =>
24
+ Object.fromEntries(
25
+ Object.entries(scopes).map(([scope, specifiers]) => [convert(scope), mapEntries(specifiers, convert)])
26
+ );
27
+
28
+ const generator = new Generator({
29
+ baseUrl: rootUrl,
30
+ combineSubpaths: true,
31
+ defaultProvider: 'jspm.io',
32
+ env: ['browser', 'module', 'production'],
33
+ flattenScopes: true,
34
+ inputMap: { imports: mapEntries(inputMap.imports, toUrl), scopes: mapScopes(inputMap.scopes, toUrl) },
35
+ inputPins: false,
36
+ mapUrl: rootUrl,
37
+ resolutions,
38
+ });
39
+
40
+ const { dynamicDeps, staticDeps } = await generator.link(entry);
41
+ const map = generator.getMap();
42
+
43
+ process.stdout.write(
44
+ JSON.stringify({
45
+ dynamicDeps: dynamicDeps.map(toPath),
46
+ imports: mapEntries(map.imports ?? {}, toPath),
47
+ scopes: mapScopes(map.scopes ?? {}, toPath),
48
+ staticDeps: staticDeps.map(toPath),
49
+ })
50
+ );
data/lib/jspm/trace.rb ADDED
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'open3'
5
+
6
+ module Jspm
7
+ class Error < StandardError
8
+ end
9
+
10
+ # What jspm's generator finds when it traces the app's entry module. The
11
+ # previous import map locks versions, as in jspm, and package-lock.json's
12
+ # versions override it. Modules on the asset path are importable by name.
13
+ Trace =
14
+ Data.define(:import_map, :urls) do
15
+ def self.call(asset_paths:, lockfile:, root:)
16
+ vendor_dir = root.join(Paths::VENDOR)
17
+ pathnames = asset_paths.map { |path| Pathname(path) }
18
+ module_paths = pathnames.reject { |path| path == vendor_dir }
19
+ local_modules = LocalModules.find(asset_paths: module_paths)
20
+ input = JSON.generate(
21
+ entry: Paths::ENTRY_PATH,
22
+ inputMap: { imports: locked_versions(root).merge(local_modules.pins), scopes: {} },
23
+ resolutions: lockfile.resolutions,
24
+ root: root.to_s
25
+ )
26
+ script = File.expand_path('trace.mjs', __dir__)
27
+ json, status = Open3.capture2('node', script, stdin_data: input)
28
+ raise(Error, "tracing #{Paths::ENTRY_PATH} failed") unless status.success?
29
+
30
+ from_trace(JSON.parse(json), local_modules)
31
+ end
32
+
33
+ # Every jspm.io module the previous map named, keyed by its specifier.
34
+ # jspm keeps the versions of packages it finds there.
35
+ def self.locked_versions(root)
36
+ import_map_file = root.join(Paths::IMPORT_MAP)
37
+ return {} unless import_map_file.exist?
38
+
39
+ import_map = JSON.parse(import_map_file.read)
40
+ specifiers = [import_map.fetch('imports'), *import_map.fetch('scopes', {}).values]
41
+ specifiers.reduce(:merge).select { |_specifier, url| url.start_with?(Paths::CDN) }
42
+ end
43
+
44
+ def self.from_trace(result, local_modules)
45
+ localize = local_modules.method(:localize)
46
+ imports = result.fetch('imports').transform_values(&localize)
47
+ scopes = localize_scopes(result.fetch('scopes'), localize)
48
+ static = result.fetch('staticDeps').map(&localize)
49
+ dynamic = result.fetch('dynamicDeps').map(&localize)
50
+ new(
51
+ import_map: { 'imports' => imports, 'scopes' => scopes, 'preload' => static },
52
+ urls: static + dynamic
53
+ )
54
+ end
55
+
56
+ # Every local directory becomes the asset root, so their scopes merge.
57
+ def self.localize_scopes(scopes, localize)
58
+ pairs =
59
+ scopes.map do |scope, specifiers|
60
+ [localize.call(scope), specifiers.transform_values(&localize)]
61
+ end
62
+ grouped = pairs.group_by(&:first)
63
+ grouped.transform_values { |entries| entries.map(&:last).reduce(:merge) }
64
+ end
65
+
66
+ def cdn_urls
67
+ urls.select { |url| url.start_with?(Paths::CDN) }
68
+ end
69
+ end
70
+ public_constant :Trace
71
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jspm
4
+ VERSION = '0.1.0'
5
+ public_constant :VERSION
6
+ end
data/lib/jspm-rails.rb ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jspm/assets'
4
+ require 'jspm/check'
5
+ require 'jspm/helper'
6
+ require 'jspm/install'
7
+ require 'jspm/local_modules'
8
+ require 'jspm/lockfile'
9
+ require 'jspm/paths'
10
+ require 'jspm/railtie'
11
+ require 'jspm/tags'
12
+ require 'jspm/trace'
13
+ require 'jspm/version'
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :jspm do
4
+ desc 'Vendor what app/javascript/application.js imports, and write config/importmap.json'
5
+ task install: :environment do
6
+ Jspm::Install.call(
7
+ asset_paths: Rails.application.config.assets.paths,
8
+ output: $stdout,
9
+ root: Rails.root
10
+ )
11
+ end
12
+
13
+ desc 'Fail if config/importmap.json or vendor/jspm is out of date'
14
+ task check: :environment do
15
+ passed = Jspm::Check.call(
16
+ asset_paths: Rails.application.config.assets.paths,
17
+ output: $stdout,
18
+ root: Rails.root
19
+ )
20
+ abort unless passed
21
+ end
22
+ end
metadata ADDED
@@ -0,0 +1,89 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jspm-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Clay Shentrup
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: propshaft
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '8.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '8.0'
40
+ description: |
41
+ Traces app/javascript/application.js with jspm, vendors every module it reaches from jspm.io,
42
+ and serves them through Propshaft. Versions lock in jspm's import map, or in package-lock.json for
43
+ packages installed with npm. No bundler, no build step.
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - CHANGELOG.md
49
+ - LICENSE
50
+ - README.md
51
+ - lib/jspm-rails.rb
52
+ - lib/jspm/assets.rb
53
+ - lib/jspm/check.rb
54
+ - lib/jspm/helper.rb
55
+ - lib/jspm/install.rb
56
+ - lib/jspm/local_modules.rb
57
+ - lib/jspm/lockfile.rb
58
+ - lib/jspm/paths.rb
59
+ - lib/jspm/railtie.rb
60
+ - lib/jspm/tags.rb
61
+ - lib/jspm/trace.mjs
62
+ - lib/jspm/trace.rb
63
+ - lib/jspm/version.rb
64
+ - lib/tasks/jspm.rake
65
+ homepage: https://github.com/ClayShentrup/jspm-rails
66
+ licenses:
67
+ - MIT
68
+ metadata:
69
+ changelog_uri: https://github.com/ClayShentrup/jspm-rails/blob/main/CHANGELOG.md
70
+ rubygems_mfa_required: 'true'
71
+ source_code_uri: https://github.com/ClayShentrup/jspm-rails
72
+ rdoc_options: []
73
+ require_paths:
74
+ - lib
75
+ required_ruby_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '3.3'
80
+ required_rubygems_version: !ruby/object:Gem::Requirement
81
+ requirements:
82
+ - - ">="
83
+ - !ruby/object:Gem::Version
84
+ version: '0'
85
+ requirements: []
86
+ rubygems_version: 4.0.16
87
+ specification_version: 4
88
+ summary: Import maps for Rails, built by jspm
89
+ test_files: []