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.
Files changed (60) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +170 -23
  3. data/exe/proscenium +19 -0
  4. data/exe/psm +5 -0
  5. data/lib/proscenium/app_package.rb +90 -0
  6. data/lib/proscenium/builder.rb +221 -20
  7. data/lib/proscenium/bundled_gems.rb +103 -0
  8. data/lib/proscenium/cli/brackets.rb +66 -0
  9. data/lib/proscenium/cli/bun_linker.rb +79 -0
  10. data/lib/proscenium/cli/capabilities.json +29 -0
  11. data/lib/proscenium/cli/collisions.rb +94 -0
  12. data/lib/proscenium/cli/contexts.rb +133 -0
  13. data/lib/proscenium/cli/diff.rb +80 -0
  14. data/lib/proscenium/cli/error.rb +291 -0
  15. data/lib/proscenium/cli/gem_archive.rb +44 -0
  16. data/lib/proscenium/cli/gem_check.rb +150 -0
  17. data/lib/proscenium/cli/gitignore.rb +63 -0
  18. data/lib/proscenium/cli/inspect.rb +257 -0
  19. data/lib/proscenium/cli/install.rb +378 -0
  20. data/lib/proscenium/cli/manager.rb +323 -0
  21. data/lib/proscenium/cli/platform_variants.rb +82 -0
  22. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  23. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  24. data/lib/proscenium/cli/project_lock.rb +106 -0
  25. data/lib/proscenium/cli/registration.rb +140 -0
  26. data/lib/proscenium/cli/reporter.rb +103 -0
  27. data/lib/proscenium/cli/rules.rb +169 -0
  28. data/lib/proscenium/cli/runner.rb +142 -0
  29. data/lib/proscenium/cli/verify.rb +107 -0
  30. data/lib/proscenium/cli.rb +122 -0
  31. data/lib/proscenium/context_map.rb +181 -0
  32. data/lib/proscenium/css_module/transformer.rb +18 -3
  33. data/lib/proscenium/css_module.rb +5 -6
  34. data/lib/proscenium/dependency_context.rb +74 -0
  35. data/lib/proscenium/error.rb +10 -0
  36. data/lib/proscenium/helper.rb +38 -4
  37. data/lib/proscenium/importer.rb +53 -42
  38. data/lib/proscenium/manifest.rb +18 -23
  39. data/lib/proscenium/mapping_generation.rb +64 -0
  40. data/lib/proscenium/middleware/base.rb +5 -10
  41. data/lib/proscenium/middleware/chunks.rb +5 -0
  42. data/lib/proscenium/monkey.rb +51 -69
  43. data/lib/proscenium/railtie.rb +16 -0
  44. data/lib/proscenium/railties/assets.rake +2 -1
  45. data/lib/proscenium/resolver.rb +43 -11
  46. data/lib/proscenium/runtime/bootstrap.js +26 -6
  47. data/lib/proscenium/runtime/server.rb +57 -36
  48. data/lib/proscenium/side_load.rb +75 -18
  49. data/lib/proscenium/stale_contexts.rb +118 -0
  50. data/lib/proscenium/templates/rescues/build_error.html.erb +9 -7
  51. data/lib/proscenium/utils.rb +55 -0
  52. data/lib/proscenium/version.rb +1 -1
  53. data/lib/proscenium/yaml_tree.rb +48 -0
  54. data/lib/proscenium.rb +7 -4
  55. metadata +42 -13
  56. data/lib/proscenium/ext/proscenium +0 -0
  57. data/lib/proscenium/ext/proscenium.h +0 -113
  58. data/lib/proscenium/react-manager/index.jsx +0 -102
  59. data/lib/proscenium/react-manager/react.js +0 -2
  60. 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
- @socket_path ||= File.join(@socket_dir ||= Dir.mktmpdir('proscenium-', '/tmp'), SOCKET_NAME)
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
- cached(:build, path, sourcemap) do
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
- # Keyed on the source file's mtime so an edit is picked up between runs of a watching runner.
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 key covers the keyed file only, and a bundled build inlines its whole graph -
463
- # which is the default, so in `--watch` editing any module a test imports can serve the
464
- # previous bundle until the test file itself is touched. Unbundled it is narrower but still
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 key.
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(kind, path, *extra)
511
+ def cached(path, *extra)
478
512
  mtime = mtime_of(path)
479
513
  return yield if mtime.nil?
480
514
 
481
- key = [kind, path, mtime, *extra]
482
- prune(kind, path, mtime)
483
-
484
- hit = @cache_mutex.synchronize { @cache[key] }
485
- return hit if hit
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
- key_mutex = @cache_mutex.synchronize { @key_mutexes[key] ||= Mutex.new }
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
- # An edited file gets a new key, and the old one would otherwise sit in both hashes for the
500
- # life of the daemon - so a long `--watch` session holds every historical build of every
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, which is what a mis-encoded source file in
537
- # the app produces. That needs reporting as a failed build rather than taking the daemon
538
- # with it, so the developer learns which module is mis-encoded.
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)
@@ -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[:preload_links_header] = false if fragments
39
- opts[:data] ||= {}
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[:preload_links_header] = false if fragments
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 = {} if options.nil?
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
- <%= Rails.gem_version >= Gem::Version.new('7.1.0') ? @exception_wrapper.exception_class_name : @exception.class.to_s %>
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: Rails.gem_version >= Gem::Version.new('7.1.0') ? @exception_wrapper : nil %>
11
+ <%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper: @exception_wrapper %>
12
12
 
13
- <% if @exception.error['location'] %>
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
- <%= @exception.error['location']['file'] %>:<%= @exception.error['location']['line'] %>:<%= @exception.error['location']['column'] %>
20
+ <%= location['File'] %>:<%= location['Line'] %>:<%= location['Column'] %>
19
21
 
20
- <%= @exception.error['location']['line'].to_s.rjust 5 %> │ <%= @exception.error['location']['line_text'] %>
21
- │ <%= (@exception.error['location']['length'] > 1 ? "~" * @exception.error['location']['length'] : "^").rjust(@exception.error['location']['column'] + @exception.error['location']['length']) %>
22
- <%- if @exception.error['location']['suggestion'].present? %> + │ <%= @exception.error['location']['suggestion'].rjust(@exception.error['location']['column'] + 1) %>
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>
@@ -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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Proscenium
4
- VERSION = '0.25.3'
4
+ VERSION = '0.26.0'
5
5
  end
@@ -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
- ALLOWED_DIRECTORIES = 'app,lib,config,node_modules'
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