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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +21 -0
- data/README.md +260 -0
- data/lib/jspm/assets.rb +46 -0
- data/lib/jspm/check.rb +67 -0
- data/lib/jspm/helper.rb +10 -0
- data/lib/jspm/install.rb +88 -0
- data/lib/jspm/local_modules.rb +73 -0
- data/lib/jspm/lockfile.rb +59 -0
- data/lib/jspm/paths.rb +23 -0
- data/lib/jspm/railtie.rb +26 -0
- data/lib/jspm/tags.rb +82 -0
- data/lib/jspm/trace.mjs +50 -0
- data/lib/jspm/trace.rb +71 -0
- data/lib/jspm/version.rb +6 -0
- data/lib/jspm-rails.rb +13 -0
- data/lib/tasks/jspm.rake +22 -0
- metadata +89 -0
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
|
+
[](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)
|
data/lib/jspm/assets.rb
ADDED
|
@@ -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
|
data/lib/jspm/helper.rb
ADDED
data/lib/jspm/install.rb
ADDED
|
@@ -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
|
data/lib/jspm/railtie.rb
ADDED
|
@@ -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
|
data/lib/jspm/trace.mjs
ADDED
|
@@ -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
|
data/lib/jspm/version.rb
ADDED
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'
|
data/lib/tasks/jspm.rake
ADDED
|
@@ -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: []
|