proscenium 0.25.3 → 0.26.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 +4 -4
- data/README.md +170 -23
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +221 -20
- data/lib/proscenium/bundled_gems.rb +103 -0
- data/lib/proscenium/cli/brackets.rb +66 -0
- data/lib/proscenium/cli/bun_linker.rb +79 -0
- data/lib/proscenium/cli/capabilities.json +29 -0
- data/lib/proscenium/cli/collisions.rb +94 -0
- data/lib/proscenium/cli/contexts.rb +133 -0
- data/lib/proscenium/cli/diff.rb +80 -0
- data/lib/proscenium/cli/error.rb +291 -0
- data/lib/proscenium/cli/gem_archive.rb +44 -0
- data/lib/proscenium/cli/gem_check.rb +150 -0
- data/lib/proscenium/cli/gitignore.rb +63 -0
- data/lib/proscenium/cli/inspect.rb +257 -0
- data/lib/proscenium/cli/install.rb +378 -0
- data/lib/proscenium/cli/manager.rb +323 -0
- data/lib/proscenium/cli/platform_variants.rb +82 -0
- data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
- data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
- data/lib/proscenium/cli/project_lock.rb +106 -0
- data/lib/proscenium/cli/registration.rb +140 -0
- data/lib/proscenium/cli/reporter.rb +103 -0
- data/lib/proscenium/cli/rules.rb +169 -0
- data/lib/proscenium/cli/runner.rb +142 -0
- data/lib/proscenium/cli/verify.rb +107 -0
- data/lib/proscenium/cli.rb +122 -0
- data/lib/proscenium/context_map.rb +181 -0
- data/lib/proscenium/css_module/transformer.rb +18 -3
- data/lib/proscenium/css_module.rb +5 -6
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/helper.rb +38 -4
- data/lib/proscenium/importer.rb +53 -42
- data/lib/proscenium/manifest.rb +18 -23
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +5 -10
- data/lib/proscenium/middleware/chunks.rb +5 -0
- data/lib/proscenium/monkey.rb +51 -69
- data/lib/proscenium/railtie.rb +16 -0
- data/lib/proscenium/railties/assets.rake +2 -1
- data/lib/proscenium/resolver.rb +43 -11
- data/lib/proscenium/runtime/bootstrap.js +26 -6
- data/lib/proscenium/runtime/server.rb +57 -36
- data/lib/proscenium/side_load.rb +75 -18
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
- data/lib/proscenium/utils.rb +55 -0
- data/lib/proscenium/version.rb +1 -1
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +7 -4
- metadata +42 -13
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +0 -113
- data/lib/proscenium/react-manager/index.jsx +0 -102
- data/lib/proscenium/react-manager/react.js +0 -2
- data/lib/proscenium/react_componentable.rb +0 -94
|
@@ -96,9 +96,9 @@ module Proscenium
|
|
|
96
96
|
@parent_pid = parent_pid
|
|
97
97
|
@requests = Queue.new
|
|
98
98
|
@cache = {}
|
|
99
|
-
@key_mutexes = {}
|
|
100
99
|
@cache_mutex = Mutex.new
|
|
101
100
|
@resolve_mutex = Mutex.new
|
|
101
|
+
@socket_mutex = Mutex.new
|
|
102
102
|
@shutdown = false
|
|
103
103
|
end
|
|
104
104
|
|
|
@@ -109,11 +109,25 @@ module Proscenium
|
|
|
109
109
|
# nobody else in the directory to race. `/tmp` explicitly, not Dir.tmpdir, because a unix
|
|
110
110
|
# socket path is capped near 104 bytes and a CI or sandboxed TMPDIR can spend most of it.
|
|
111
111
|
#
|
|
112
|
+
# Except on Windows, which has no `/tmp`. Dir.tmpdir there is normally the per-user
|
|
113
|
+
# `AppData\Local\Temp`, which is short enough and private to the user by its default ACL -
|
|
114
|
+
# which matters, because the 0700 mktmpdir asks for means nothing on Windows. That is a
|
|
115
|
+
# default, not a guarantee: TMP or TEMP can point somewhere shared. Only a caller that
|
|
116
|
+
# supplies no socket_dir lands here; the bun test harness always supplies one, under the
|
|
117
|
+
# app's own tmp/.
|
|
118
|
+
#
|
|
112
119
|
# Resolved on first use rather than in the constructor, because `handle` can be driven
|
|
113
120
|
# without ever binding anything - most of this class' own tests do - and a constructor that
|
|
114
121
|
# creates a directory would leave one behind for every such instance.
|
|
122
|
+
#
|
|
123
|
+
# Guarded, because `start` reads it on the server thread while a caller may read it on
|
|
124
|
+
# another: an unguarded `||=` let both see nil and make a directory each, and the caller then
|
|
125
|
+
# waited for a socket the server had not bound.
|
|
115
126
|
def socket_path
|
|
116
|
-
@
|
|
127
|
+
@socket_mutex.synchronize do
|
|
128
|
+
@socket_dir ||= Dir.mktmpdir('proscenium-', Gem.win_platform? ? Dir.tmpdir : '/tmp')
|
|
129
|
+
@socket_path ||= File.join(@socket_dir, SOCKET_NAME)
|
|
130
|
+
end
|
|
117
131
|
end
|
|
118
132
|
|
|
119
133
|
def start
|
|
@@ -236,6 +250,7 @@ module Proscenium
|
|
|
236
250
|
# absPath - the real file on disk, for the runtime's own resolver
|
|
237
251
|
def op_resolve(request)
|
|
238
252
|
path = request.fetch('path')
|
|
253
|
+
refuse_while_installing!
|
|
239
254
|
|
|
240
255
|
# `Resolver.resolved` is a plain class-level Hash with no synchronisation, so concurrent
|
|
241
256
|
# resolves would race on it. Resolution is cheap and memoised, so serialising it costs
|
|
@@ -269,9 +284,16 @@ module Proscenium
|
|
|
269
284
|
# and then stripped by the plugin.
|
|
270
285
|
sourcemap = request.fetch('sourcemap', true) ? true : false
|
|
271
286
|
|
|
272
|
-
|
|
287
|
+
refuse_while_installing!
|
|
288
|
+
cached(path, sourcemap) do
|
|
273
289
|
code = serve_or_build(path, sourcemap: sourcemap)
|
|
274
290
|
|
|
291
|
+
# esbuild passes a mis-encoded source's bytes through, and scanning them below would
|
|
292
|
+
# raise a bare "invalid byte sequence in UTF-8" that names no module.
|
|
293
|
+
unless code.valid_encoding?
|
|
294
|
+
raise EncodingError, "#{path} built to output that is not valid UTF-8"
|
|
295
|
+
end
|
|
296
|
+
|
|
275
297
|
# A source map has no imports to resolve, and the client discards the field, so scanning
|
|
276
298
|
# it is work nobody reads.
|
|
277
299
|
path.end_with?('.map') ? { code: code } : { code: code, imports: resolve_imports(code) }
|
|
@@ -350,6 +372,9 @@ module Proscenium
|
|
|
350
372
|
|
|
351
373
|
content = +''
|
|
352
374
|
body.each { |chunk| content << chunk }
|
|
375
|
+
# A file served as it is, through ActionDispatch::FileHandler, comes in binary chunks.
|
|
376
|
+
# Everything served here is text, which the content type guard below ensures.
|
|
377
|
+
content.force_encoding(Encoding::UTF_8)
|
|
353
378
|
|
|
354
379
|
return nil if status == 404
|
|
355
380
|
|
|
@@ -457,53 +482,49 @@ module Proscenium
|
|
|
457
482
|
content
|
|
458
483
|
end
|
|
459
484
|
|
|
460
|
-
#
|
|
485
|
+
# Before the cache, so an earlier result is not handed out while an install changes what it
|
|
486
|
+
# resolved against, or while contexts are stale (C24).
|
|
487
|
+
def refuse_while_installing!
|
|
488
|
+
reason = Proscenium::Builder.refusal
|
|
489
|
+
raise reason if reason
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# Each module variant - its path and whether it carries a source map - holds the mtime of its
|
|
493
|
+
# source file, so an edit is picked up between runs of a watching runner.
|
|
461
494
|
#
|
|
462
|
-
# ponytail: the
|
|
463
|
-
# which is the default, so in `--watch` editing any module a test imports can serve
|
|
464
|
-
# previous bundle until the test file itself is touched. Unbundled it is narrower but
|
|
465
|
-
# real: a CSS module, an SVG and i18n data are inlined either way. `Metafile: true` is
|
|
495
|
+
# ponytail: the mtime covers the keyed file only, and a bundled build inlines its whole
|
|
496
|
+
# graph - which is the default, so in `--watch` editing any module a test imports can serve
|
|
497
|
+
# the previous bundle until the test file itself is touched. Unbundled it is narrower but
|
|
498
|
+
# still real: a CSS module, an SVG and i18n data are inlined either way. `Metafile: true` is
|
|
466
499
|
# already set in internal/builder/build.go, so keying on the newest mtime across the
|
|
467
500
|
# metafile's inputs is the fix when this bites.
|
|
501
|
+
#
|
|
468
502
|
# Held across the build, not just around the hash read, so N concurrent requests for the same
|
|
469
503
|
# module build it once and the rest wait for that result. A single mutex around the whole
|
|
470
|
-
# thing would serialise every build and defeat the worker pool, so the lock is per
|
|
504
|
+
# thing would serialise every build and defeat the worker pool, so the lock is per variant.
|
|
471
505
|
#
|
|
472
506
|
# A path with nothing on disk to key on is not cached at all. `.rjs` is rendered by a route,
|
|
473
507
|
# so its bytes depend on app code that can change; keying it on a nil mtime made the key a
|
|
474
508
|
# constant and pinned the first render for the life of the daemon. That is invisible in a
|
|
475
509
|
# one-shot run and wrong in a watching one - a suite passing against bytes the app no longer
|
|
476
510
|
# produces. A route render is cheap next to a build, so it happens each time instead.
|
|
477
|
-
def cached(
|
|
511
|
+
def cached(path, *extra)
|
|
478
512
|
mtime = mtime_of(path)
|
|
479
513
|
return yield if mtime.nil?
|
|
480
514
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
515
|
+
# One bucket per module variant. A new mtime replaces the bucket, lock and all, which is
|
|
516
|
+
# what keeps a long `--watch` session at one entry per module - and holds for a build that
|
|
517
|
+
# raised, which leaves its bucket without a value rather than a lock outside the cache.
|
|
518
|
+
key = [path, Proscenium::Builder.generation, *extra]
|
|
519
|
+
bucket = @cache_mutex.synchronize do
|
|
520
|
+
current = @cache[key]
|
|
521
|
+
next current if current && current[:mtime] == mtime
|
|
486
522
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
key_mutex.synchronize do
|
|
490
|
-
hit = @cache_mutex.synchronize { @cache[key] }
|
|
491
|
-
next hit if hit
|
|
492
|
-
|
|
493
|
-
value = yield
|
|
494
|
-
@cache_mutex.synchronize { @cache[key] = value }
|
|
495
|
-
value
|
|
523
|
+
@cache[key] = { mtime: mtime, mutex: Mutex.new }
|
|
496
524
|
end
|
|
497
|
-
end
|
|
498
525
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
# module it ever saw. Dropping the superseded generations keeps one entry per module. Every
|
|
502
|
-
# variant of a superseded mtime goes, whatever else is in its key.
|
|
503
|
-
def prune(kind, path, mtime)
|
|
504
|
-
@cache_mutex.synchronize do
|
|
505
|
-
stale = @cache.keys.select { |k| k[0] == kind && k[1] == path && k[2] != mtime }
|
|
506
|
-
stale.each { |k| @cache.delete(k) && @key_mutexes.delete(k) }
|
|
526
|
+
bucket[:mutex].synchronize do
|
|
527
|
+
bucket.key?(:value) ? bucket[:value] : (bucket[:value] = yield)
|
|
507
528
|
end
|
|
508
529
|
end
|
|
509
530
|
|
|
@@ -533,9 +554,9 @@ module Proscenium
|
|
|
533
554
|
end
|
|
534
555
|
|
|
535
556
|
# `JSON.generate` raises `JSON::GeneratorError` - a StandardError, not an IOError - when the
|
|
536
|
-
# reply carries bytes that are not valid UTF-8
|
|
537
|
-
# the
|
|
538
|
-
#
|
|
557
|
+
# reply carries bytes that are not valid UTF-8. `op_build` checks its own output first and
|
|
558
|
+
# names the module; this is the backstop for any other reply, reported as a failure rather
|
|
559
|
+
# than taking the daemon with it.
|
|
539
560
|
def answer(socket, write_mutex, reply)
|
|
540
561
|
line = begin
|
|
541
562
|
JSON.generate(reply)
|
data/lib/proscenium/side_load.rb
CHANGED
|
@@ -35,8 +35,8 @@ module Proscenium
|
|
|
35
35
|
out = []
|
|
36
36
|
Proscenium::Importer.each_stylesheet(delete: true) do |path, opts|
|
|
37
37
|
opts = opts[:css].is_a?(Hash) ? opts[:css] : {}
|
|
38
|
-
opts[:
|
|
39
|
-
opts
|
|
38
|
+
opts = opts.merge(data: opts[:data] || {})
|
|
39
|
+
opts = opts.merge(preload_links_header: false) if fragments
|
|
40
40
|
|
|
41
41
|
out << helpers.stylesheet_link_tag(path.delete_prefix('/'), extname: false, **opts)
|
|
42
42
|
end
|
|
@@ -62,7 +62,7 @@ module Proscenium
|
|
|
62
62
|
next if opts.delete(:lazy)
|
|
63
63
|
|
|
64
64
|
opts = opts[:js].is_a?(Hash) ? opts[:js] : {}
|
|
65
|
-
opts
|
|
65
|
+
opts = opts.merge(preload_links_header: false) if fragments
|
|
66
66
|
|
|
67
67
|
out << helpers.javascript_include_tag(path.delete_prefix('/'), extname: false, **opts)
|
|
68
68
|
end
|
|
@@ -98,21 +98,7 @@ module Proscenium
|
|
|
98
98
|
def sideload_inheritance_chain(obj, options)
|
|
99
99
|
return unless Proscenium.config.side_load
|
|
100
100
|
|
|
101
|
-
options =
|
|
102
|
-
options = { js: options, css: options } unless options.is_a?(Hash)
|
|
103
|
-
|
|
104
|
-
unless obj.sideload_assets_options.nil?
|
|
105
|
-
tpl_options = obj.sideload_assets_options
|
|
106
|
-
options = if tpl_options.is_a?(Hash)
|
|
107
|
-
options.deep_merge tpl_options
|
|
108
|
-
else
|
|
109
|
-
{ js: tpl_options, css: tpl_options }
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
%i[css js].each do |k|
|
|
114
|
-
options[k] = obj.instance_eval(&options[k]) if options[k].is_a?(Proc)
|
|
115
|
-
end
|
|
101
|
+
options = merge_options(options, obj.sideload_assets_options, obj)
|
|
116
102
|
|
|
117
103
|
css_imports = []
|
|
118
104
|
|
|
@@ -148,6 +134,77 @@ module Proscenium
|
|
|
148
134
|
Importer.sideload_css it, **options
|
|
149
135
|
end
|
|
150
136
|
end
|
|
137
|
+
|
|
138
|
+
# Whether `template`, rendered by `view`, should have its assets side loaded.
|
|
139
|
+
def sideloadable?(view, template)
|
|
140
|
+
Proscenium.config.side_load && view.controller.respond_to?(:sideload_assets_options) &&
|
|
141
|
+
template.respond_to?(:identifier) && template.respond_to?(:type) && template.type == :html
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Renders the block, then side loads each of `templates`, and returns what the block
|
|
145
|
+
# returned. A template's `sideload_assets` value belongs to this one render: the view's
|
|
146
|
+
# stored value is set aside before the block and put back after it, so a repeated or nested
|
|
147
|
+
# render of the same template neither inherits this one's value nor overwrites it.
|
|
148
|
+
def sideload_templates(view, templates)
|
|
149
|
+
# A layout can be `false`, meaning none, and a template can be its own layout.
|
|
150
|
+
templates = templates.select(&:itself).uniq(&:identifier)
|
|
151
|
+
# A view rendered before Proscenium::Helper is included has nowhere to store a value.
|
|
152
|
+
store = view.try(:proscenium_sideload_assets_options) || {}
|
|
153
|
+
saved = templates.to_h { |tpl| [tpl.identifier, store.delete(tpl.identifier)] }
|
|
154
|
+
|
|
155
|
+
result = yield
|
|
156
|
+
templates.each { |tpl| sideload_template tpl, view.controller, store[tpl.identifier] }
|
|
157
|
+
result
|
|
158
|
+
ensure
|
|
159
|
+
saved&.each { |id, value| value.nil? ? store.delete(id) : store[id] = value }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Returns a new options hash: `base`, with `override` deep merged over it, or replacing it
|
|
163
|
+
# when not a Hash, and any Proc `css`/`js` value evaluated against `receiver`. A nil `base`
|
|
164
|
+
# is empty, and a nil `override` changes nothing. Keys are symbolized at every depth, before
|
|
165
|
+
# merging, so options with indifferent access merge with symbol-keyed ones.
|
|
166
|
+
#
|
|
167
|
+
# Neither input is modified, and no Hash is shared with them - `base` is usually a class
|
|
168
|
+
# attribute, so writing to it would leak one request's options into every later one. Values
|
|
169
|
+
# other than hashes are passed through as they are, not copied: `dup` on an ActiveRecord
|
|
170
|
+
# model returns a new record with no id.
|
|
171
|
+
def merge_options(base, override, receiver)
|
|
172
|
+
options = base.nil? ? {} : base
|
|
173
|
+
options = { js: options, css: options } unless options.is_a?(Hash)
|
|
174
|
+
options = options.deep_symbolize_keys
|
|
175
|
+
|
|
176
|
+
unless override.nil?
|
|
177
|
+
options = if override.is_a?(Hash)
|
|
178
|
+
options.deep_merge(override.deep_symbolize_keys)
|
|
179
|
+
else
|
|
180
|
+
{ js: override, css: override }
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
options.to_h do |key, value|
|
|
185
|
+
value = evaluate(value, receiver) if (key in :css | :js) && value.is_a?(Proc)
|
|
186
|
+
[key, value.is_a?(Hash) ? value.deep_symbolize_keys : value]
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
private
|
|
191
|
+
|
|
192
|
+
# `instance_eval` passes the receiver as the block's argument, which a lambda taking none
|
|
193
|
+
# rejects. Every other proc keeps receiving it, as it always has.
|
|
194
|
+
def evaluate(value, receiver)
|
|
195
|
+
if value.lambda? && value.arity.zero?
|
|
196
|
+
receiver.instance_exec(&value)
|
|
197
|
+
else
|
|
198
|
+
receiver.instance_eval(&value)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def sideload_template(tpl, controller, override)
|
|
203
|
+
return unless (tpl_path = Pathname.new(tpl.identifier)).file?
|
|
204
|
+
|
|
205
|
+
options = merge_options(controller.sideload_assets_options, override, controller)
|
|
206
|
+
Importer.sideload tpl_path, **options
|
|
207
|
+
end
|
|
151
208
|
end
|
|
152
209
|
end
|
|
153
210
|
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative 'bundled_gems'
|
|
5
|
+
require_relative 'context_map'
|
|
6
|
+
require_relative 'dependency_context'
|
|
7
|
+
require_relative 'cli/contexts'
|
|
8
|
+
require_relative 'cli/error'
|
|
9
|
+
require_relative 'cli/verify'
|
|
10
|
+
|
|
11
|
+
module Proscenium
|
|
12
|
+
# Whether an adopted app's committed gem dependency contexts are what its bundle calls for
|
|
13
|
+
# (#154). A stale context resolves a gem's imports against the wrong dependency graph, so the
|
|
14
|
+
# engine refuses to build until `proscenium install` brings them up to date. Built from
|
|
15
|
+
# committed files and Bundler only; it loads nothing the `proscenium` CLI may not.
|
|
16
|
+
module StaleContexts
|
|
17
|
+
COMMAND = 'Run `bundle exec proscenium install` and commit .proscenium/packages.'
|
|
18
|
+
EDITED = 'its dependency context was edited by hand'
|
|
19
|
+
ORPHAN = '%<gem>s: its dependency context is left over, as the gem is no longer in the ' \
|
|
20
|
+
'bundle or no longer opted in'
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# One line for each way the committed contexts differ from the bundle; empty when current, or
|
|
25
|
+
# before the app adopts. A context is stale when a participating gem has none, when its gem
|
|
26
|
+
# left Gemfile.lock or stopped participating, when its projection hash is not the gem's
|
|
27
|
+
# current one, or when that hash is not the one its own fields produce (a hand edit). A
|
|
28
|
+
# locked gem that is not installed (a group BUNDLE_WITHOUT excludes) keeps its context as
|
|
29
|
+
# committed.
|
|
30
|
+
def problems(root, specs = BundledGems.installed_specs, locked = BundledGems.locked_names)
|
|
31
|
+
registrar = ContextMap.registrar(root)
|
|
32
|
+
return [] unless registrar
|
|
33
|
+
|
|
34
|
+
# Only the registration in use: a Bun app does not read a pnpm-workspace.yaml left over.
|
|
35
|
+
unparsable = unparsable_workspace(root) if registrar == 'pnpm-workspace.yaml'
|
|
36
|
+
return [unparsable] if unparsable
|
|
37
|
+
|
|
38
|
+
participating = BundledGems.participating(specs, overrides: BundledGems.overrides(root))
|
|
39
|
+
contexts = CLI::Contexts.new(root, participating)
|
|
40
|
+
committed = CLI::Contexts.committed(root)
|
|
41
|
+
found = contexts.problems.map { |code, args| CLI::Error.new(code, **args).message }
|
|
42
|
+
contexts.contexts.each_value do |context|
|
|
43
|
+
problem = compare(context, committed[context.gem])
|
|
44
|
+
found << "#{context.gem}: #{problem}" if problem
|
|
45
|
+
end
|
|
46
|
+
excluded = (locked || []) - specs.map(&:name)
|
|
47
|
+
orphans = committed.keys - participating.keys - excluded
|
|
48
|
+
found + orphans.map { format(ORPHAN, gem: it) } +
|
|
49
|
+
split_peers(root, contexts.contexts.keys & committed.keys)
|
|
50
|
+
rescue BundledGems::ConfigError => e
|
|
51
|
+
[e.message]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# An app counts as adopted when a pnpm-workspace.yaml it cannot parse names the contexts, so
|
|
55
|
+
# the engine never silently switches them off; it refuses until the file parses. Not
|
|
56
|
+
# package.json: Bun reads JSON that Ruby refuses, such as a trailing comma.
|
|
57
|
+
def unparsable_workspace(root)
|
|
58
|
+
path = File.join(root, 'pnpm-workspace.yaml')
|
|
59
|
+
return unless File.exist?(path)
|
|
60
|
+
|
|
61
|
+
ContextMap.yaml_packages(ContextMap.utf8(File.read(path)).delete_prefix(ContextMap::BOM))
|
|
62
|
+
nil
|
|
63
|
+
rescue Psych::SyntaxError => e
|
|
64
|
+
"pnpm-workspace.yaml: it is not valid YAML (#{e.problem} at line #{e.line})"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A peer meant to be shared that the gem and the app reach as two copies, as an app-only
|
|
68
|
+
# `pnpm update react` can leave it: install checks this too, and the engine repeats it for each
|
|
69
|
+
# generation.
|
|
70
|
+
def split_peers(root, gems)
|
|
71
|
+
pnpm = ContextMap.registers?(root, 'pnpm-workspace.yaml')
|
|
72
|
+
CLI::Verify.new(root, Struct.new(:name).new(pnpm ? 'pnpm' : 'bun'), gems).check_peers
|
|
73
|
+
[]
|
|
74
|
+
rescue CLI::Error => e
|
|
75
|
+
[e.message]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The committed context is projected again from the fields it holds, not taken on trust: a
|
|
79
|
+
# hand edit that kept the reported hash would build against a graph no install produced.
|
|
80
|
+
def compare(context, path)
|
|
81
|
+
return "its dependency context hasn't been written yet" unless path && File.exist?(path)
|
|
82
|
+
|
|
83
|
+
committed = ContextMap.read_json(path)
|
|
84
|
+
unless committed.is_a?(Hash) && committed['proscenium'].is_a?(Hash)
|
|
85
|
+
return 'its dependency context is not valid JSON'
|
|
86
|
+
end
|
|
87
|
+
if committed['proscenium']['projection'] != DependencyContext::PROJECTION
|
|
88
|
+
return 'its dependency context was written by a different version of Proscenium'
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Parsed, so reformatting is not an edit; whole, so its name and the other generated fields
|
|
92
|
+
# count as well as its hash.
|
|
93
|
+
edited = DependencyContext.project(context.gem, committed) != committed
|
|
94
|
+
return EDITED if edited
|
|
95
|
+
return if committed == JSON.parse(context.json)
|
|
96
|
+
|
|
97
|
+
'its NPM dependencies changed since its dependency context was written'
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
'its dependency context is not valid JSON'
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The build error for the app at `root`, or nil when its contexts are current. Read once per
|
|
103
|
+
# mapping generation, as ContextMap.config is.
|
|
104
|
+
def message(root, generation = 0)
|
|
105
|
+
@message ||= {}
|
|
106
|
+
key = [root, generation]
|
|
107
|
+
return @message[key] if @message.key?(key)
|
|
108
|
+
|
|
109
|
+
found = problems(root)
|
|
110
|
+
@message[key] = if found.any?
|
|
111
|
+
"Gem dependency contexts are out of date:\n" \
|
|
112
|
+
"#{found.map { " - #{it}" }.join("\n")}\n#{COMMAND}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def reset! = @message = {}
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<header>
|
|
2
2
|
<h1>
|
|
3
|
-
<%=
|
|
3
|
+
<%= @exception_wrapper.exception_class_name %>
|
|
4
4
|
<% if params_valid? && @request.parameters['controller'] %>
|
|
5
5
|
in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %>
|
|
6
6
|
<% end %>
|
|
@@ -8,18 +8,20 @@
|
|
|
8
8
|
</header>
|
|
9
9
|
|
|
10
10
|
<main role="main" id="container">
|
|
11
|
-
<%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper:
|
|
11
|
+
<%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper: @exception_wrapper %>
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
<%# Go marshals esbuild's Message without JSON tags, so the keys are the Go field names. This
|
|
14
|
+
block read lowercase keys and had never rendered. %>
|
|
15
|
+
<% if (location = @exception.error['Location']) %>
|
|
14
16
|
<div class="source">
|
|
15
17
|
<div class="data">
|
|
16
18
|
<pre>
|
|
17
19
|
|
|
18
|
-
<%=
|
|
20
|
+
<%= location['File'] %>:<%= location['Line'] %>:<%= location['Column'] %>
|
|
19
21
|
|
|
20
|
-
<%=
|
|
21
|
-
│ <%= (
|
|
22
|
-
<%- if
|
|
22
|
+
<%= location['Line'].to_s.rjust 5 %> │ <%= location['LineText'] %>
|
|
23
|
+
│ <%= (location['Length'].to_i > 1 ? "~" * location['Length'] : "^").rjust(location['Column'].to_i + location['Length'].to_i) %>
|
|
24
|
+
<%- if location['Suggestion'].present? %> + │ <%= location['Suggestion'].rjust(location['Column'].to_i + 1) %>
|
|
23
25
|
<% else %> <%- end %>
|
|
24
26
|
</pre>
|
|
25
27
|
</div>
|
data/lib/proscenium/utils.rb
CHANGED
|
@@ -4,6 +4,20 @@ module Proscenium
|
|
|
4
4
|
module Utils
|
|
5
5
|
module_function
|
|
6
6
|
|
|
7
|
+
# A filesystem path in the slash form everything it is matched against - Rails.root, the public
|
|
8
|
+
# path, a gem's full_gem_path - already uses. Paths produced outside Ruby arrive in the form
|
|
9
|
+
# the platform produced: esbuild's metafile, and every path Bun hands the test harness. On
|
|
10
|
+
# Windows that is backslashes, so a plain `start_with?`/`delete_prefix` against Rails.root
|
|
11
|
+
# misses, and nothing raises - the path just falls through to whatever handles "not ours".
|
|
12
|
+
#
|
|
13
|
+
# Windows only, because a backslash is a legal character in a file name everywhere else.
|
|
14
|
+
#
|
|
15
|
+
# @param path [String] an absolute filesystem path.
|
|
16
|
+
# @return [String]
|
|
17
|
+
def fs_path(path)
|
|
18
|
+
Gem.win_platform? ? path.tr('\\', '/') : path
|
|
19
|
+
end
|
|
20
|
+
|
|
7
21
|
# Returns a short digest for the given `value`, intended for CSS module class name suffixes.
|
|
8
22
|
#
|
|
9
23
|
# @param value [#to_s] The value to create the digest from. This will usually be the absolute
|
|
@@ -13,6 +27,47 @@ module Proscenium
|
|
|
13
27
|
Digest::SHA1.hexdigest(value.to_s)[..7]
|
|
14
28
|
end
|
|
15
29
|
|
|
30
|
+
# The path a CSS module's class-name suffix is built from: the file's path under the app root.
|
|
31
|
+
#
|
|
32
|
+
# Where there is no such path - a gem on another drive than the app, which is RubyInstaller's
|
|
33
|
+
# default - it is the absolute path itself. relative_path_from raised there, and failed every
|
|
34
|
+
# view using the module. The absolute path is what esbuild falls back to when it builds the
|
|
35
|
+
# stylesheet's class names (MakePrettyPaths keeps the path it could not relativise), and
|
|
36
|
+
# internal/plugin/css.go does the same, so all three agree.
|
|
37
|
+
#
|
|
38
|
+
# @param abs_path [String] absolute file system path of the CSS module.
|
|
39
|
+
# @return [Pathname, String]
|
|
40
|
+
def css_module_relative_path(abs_path)
|
|
41
|
+
Pathname.new(abs_path).relative_path_from(Rails.root)
|
|
42
|
+
rescue ArgumentError
|
|
43
|
+
abs_path
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Returns the readable part of a CSS module class name: the given path, made safe to use in a
|
|
47
|
+
# class name.
|
|
48
|
+
#
|
|
49
|
+
# This is a port of `CssLocalAppendice` in esbuild-internal, which builds the class names in the
|
|
50
|
+
# stylesheet. The two must agree byte for byte or the style silently does not apply, so change
|
|
51
|
+
# them together (test/css_module_suffixes.json is checked against both). The rules: drop the
|
|
52
|
+
# file extension, turn `/`, `\` and `.` into `-`, turn any other character outside
|
|
53
|
+
# `A-Z a-z 0-9 - _` into `_`, and start with `_` if the result would start with a digit.
|
|
54
|
+
#
|
|
55
|
+
# Go works in runes, so the path is read as UTF-8 whatever its tag: the FFI hands paths back as
|
|
56
|
+
# ASCII-8BIT, where each byte of a multibyte character would become a `_` of its own. A byte
|
|
57
|
+
# that is not valid UTF-8 becomes one `_`, as it does in Go.
|
|
58
|
+
#
|
|
59
|
+
# @param path [#to_s] path of the CSS module file, relative to the app root.
|
|
60
|
+
# @return [String]
|
|
61
|
+
def css_module_suffix(path)
|
|
62
|
+
path.to_s
|
|
63
|
+
.dup.force_encoding(Encoding::UTF_8)
|
|
64
|
+
.scrub { |bytes| '_' * bytes.bytesize }
|
|
65
|
+
.sub(%r{\.[^./]*\z}, '')
|
|
66
|
+
.gsub(%r{[/\\.]}, '-')
|
|
67
|
+
.gsub(/[^A-Za-z0-9_-]/, '_')
|
|
68
|
+
.sub(/\A(?=\d)/, '_')
|
|
69
|
+
end
|
|
70
|
+
|
|
16
71
|
# Merges the given array of attribute `name`'s into the `kw_arguments`. A bang attribute is one
|
|
17
72
|
# that ends with an exclamation mark or - in Ruby parlance - a "bang", and has a boolean value.
|
|
18
73
|
# Modifies the given `kw_attributes`, and only attribute names in `allowed` will be merged.
|
data/lib/proscenium/version.rb
CHANGED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Proscenium
|
|
6
|
+
# YAML read from Psych's parse tree, never built into Ruby objects (#154): no alias can make it
|
|
7
|
+
# slow, and no tag or date can raise. Shared by the engine and the `proscenium` CLI, so it loads
|
|
8
|
+
# nothing the CLI may not.
|
|
9
|
+
module YamlTree
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# The node a root mapping's last `key` holds, aliases resolved, and the document's anchors; nil
|
|
13
|
+
# when the document is not a mapping or has no such key. A key the root inherits through a
|
|
14
|
+
# merge key (`<<: *defaults`) counts, as pnpm reads it. Raises Psych::SyntaxError.
|
|
15
|
+
def value(text, key)
|
|
16
|
+
root = Psych.parse_stream(text).children.first&.root
|
|
17
|
+
return unless root.is_a?(Psych::Nodes::Mapping)
|
|
18
|
+
|
|
19
|
+
anchors = root.each.grep_v(Psych::Nodes::Alias).select(&:anchor).to_h { [it.anchor, it] }
|
|
20
|
+
[lookup(root, key, anchors), anchors]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `key`'s node in `mapping`: its own last one, else the first merged mapping's, following
|
|
24
|
+
# merges until one is already being looked in.
|
|
25
|
+
def lookup(mapping, key, anchors, merging = [])
|
|
26
|
+
return unless mapping.is_a?(Psych::Nodes::Mapping) && merging.none? { it.equal?(mapping) }
|
|
27
|
+
|
|
28
|
+
pairs = mapping.children.each_slice(2).map { |name, value| [node(name, anchors), value] }
|
|
29
|
+
_, found = pairs.reverse_each.find { |name, _| scalar?(name, key) }
|
|
30
|
+
return node(found, anchors) if found
|
|
31
|
+
|
|
32
|
+
merges = pairs.filter_map { |name, value| node(value, anchors) if scalar?(name, '<<') }
|
|
33
|
+
merges.each do |merged|
|
|
34
|
+
sources = merged.is_a?(Psych::Nodes::Sequence) ? merged.children : [merged]
|
|
35
|
+
sources.each do |source|
|
|
36
|
+
inherited = lookup(node(source, anchors), key, anchors, [*merging, mapping])
|
|
37
|
+
return inherited if inherited
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scalar?(node, value) = node.is_a?(Psych::Nodes::Scalar) && node.value == value
|
|
44
|
+
|
|
45
|
+
# The node an alias names, or the node itself.
|
|
46
|
+
def node(node, anchors) = node.is_a?(Psych::Nodes::Alias) ? anchors[node.anchor] : node
|
|
47
|
+
end
|
|
48
|
+
end
|
data/lib/proscenium.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'active_support'
|
|
4
|
+
require_relative 'proscenium/error'
|
|
4
5
|
|
|
5
6
|
module Proscenium
|
|
6
7
|
extend ActiveSupport::Autoload
|
|
@@ -14,7 +15,9 @@ module Proscenium
|
|
|
14
15
|
|
|
15
16
|
FILE_EXTENSIONS = ['js', 'mjs', 'ts', 'jsx', 'tsx', 'css', 'js.map', 'mjs.map', 'jsx.map',
|
|
16
17
|
'ts.map', 'tsx.map', 'css.map'].freeze
|
|
17
|
-
|
|
18
|
+
# A gem dependency context's own node_modules holds the copy of a package the linker nested
|
|
19
|
+
# there, when the gem pins a version the app's does not satisfy (#154).
|
|
20
|
+
ALLOWED_DIRECTORIES = 'app,lib,config,node_modules,.proscenium/packages/*/node_modules'
|
|
18
21
|
VENDOR_PATH_GLOB = '/vendor/**.{js,css}'
|
|
19
22
|
APP_PATH_GLOB = "/{#{ALLOWED_DIRECTORIES}}/**.{#{FILE_EXTENSIONS.join(',')}}".freeze
|
|
20
23
|
GEMS_PATH_GLOB = "/node_modules/@rubygems/**.{#{FILE_EXTENSIONS.join(',')}}".freeze
|
|
@@ -31,12 +34,14 @@ module Proscenium
|
|
|
31
34
|
autoload :EnsureLoaded
|
|
32
35
|
autoload :SideLoad
|
|
33
36
|
autoload :CssModule
|
|
34
|
-
autoload :ReactComponentable
|
|
35
37
|
autoload :Helper
|
|
36
38
|
autoload :Builder
|
|
37
39
|
autoload :Importer
|
|
38
40
|
autoload :Resolver
|
|
39
41
|
autoload :BundledGems
|
|
42
|
+
autoload :ContextMap
|
|
43
|
+
autoload :StaleContexts
|
|
44
|
+
autoload :MappingGeneration
|
|
40
45
|
|
|
41
46
|
class Deprecator
|
|
42
47
|
def deprecation_warning(name, message, _caller_backtrace = nil)
|
|
@@ -46,8 +51,6 @@ module Proscenium
|
|
|
46
51
|
end
|
|
47
52
|
end
|
|
48
53
|
|
|
49
|
-
class Error < StandardError; end
|
|
50
|
-
|
|
51
54
|
class MissingAssetError < Error
|
|
52
55
|
def initialize(path)
|
|
53
56
|
super
|