proscenium 0.26.0-x86_64-linux-gnu
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/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +1020 -0
- data/exe/proscenium +19 -0
- data/exe/psm +5 -0
- data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
- data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
- data/lib/proscenium/app_package.rb +90 -0
- data/lib/proscenium/builder.rb +403 -0
- data/lib/proscenium/bundled_gems.rb +140 -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/path.rb +32 -0
- data/lib/proscenium/css_module/transformer.rb +118 -0
- data/lib/proscenium/css_module.rb +63 -0
- data/lib/proscenium/dependency_context.rb +74 -0
- data/lib/proscenium/ensure_loaded.rb +31 -0
- data/lib/proscenium/error.rb +10 -0
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +113 -0
- data/lib/proscenium/helper.rb +82 -0
- data/lib/proscenium/importer.rb +189 -0
- data/lib/proscenium/log_subscriber.rb +38 -0
- data/lib/proscenium/manifest.rb +48 -0
- data/lib/proscenium/mapping_generation.rb +64 -0
- data/lib/proscenium/middleware/base.rb +102 -0
- data/lib/proscenium/middleware/chunks.rb +46 -0
- data/lib/proscenium/middleware/esbuild.rb +11 -0
- data/lib/proscenium/middleware/ruby_gems.rb +37 -0
- data/lib/proscenium/middleware/silence_request.rb +31 -0
- data/lib/proscenium/middleware/vendor.rb +31 -0
- data/lib/proscenium/middleware.rb +67 -0
- data/lib/proscenium/monkey.rb +77 -0
- data/lib/proscenium/railtie.rb +106 -0
- data/lib/proscenium/railties/assets.rake +19 -0
- data/lib/proscenium/resolver.rb +67 -0
- data/lib/proscenium/runtime/bootstrap.js +266 -0
- data/lib/proscenium/runtime/bun.js +143 -0
- data/lib/proscenium/runtime/server.rb +702 -0
- data/lib/proscenium/side_load.rb +210 -0
- data/lib/proscenium/source_path.rb +15 -0
- data/lib/proscenium/stale_contexts.rb +118 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
- data/lib/proscenium/utils.rb +104 -0
- data/lib/proscenium/version.rb +5 -0
- data/lib/proscenium/yaml_tree.rb +48 -0
- data/lib/proscenium.rb +87 -0
- metadata +148 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
class SideLoad
|
|
5
|
+
JS_COMMENT = '<!-- [PROSCENIUM_JAVASCRIPTS] -->'
|
|
6
|
+
CSS_COMMENT = '<!-- [PROSCENIUM_STYLESHEETS] -->'
|
|
7
|
+
|
|
8
|
+
module Controller
|
|
9
|
+
def self.included(child)
|
|
10
|
+
child.class_eval do
|
|
11
|
+
class_attribute :sideload_assets_options
|
|
12
|
+
child.extend ClassMethods
|
|
13
|
+
|
|
14
|
+
append_after_action :capture_and_replace_proscenium_stylesheets,
|
|
15
|
+
:capture_and_replace_proscenium_javascripts,
|
|
16
|
+
if: -> { response.content_type&.include?('html') }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
module ClassMethods
|
|
21
|
+
def sideload_assets(value)
|
|
22
|
+
self.sideload_assets_options = value
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capture_and_replace_proscenium_stylesheets
|
|
27
|
+
return if response_body.nil?
|
|
28
|
+
return if response_body.first.blank? || !Proscenium::Importer.css_imported?
|
|
29
|
+
|
|
30
|
+
included_comment = response_body.first.include?(CSS_COMMENT)
|
|
31
|
+
fragments = proscenium_fragment_header&.split(/[,\s]+/)
|
|
32
|
+
|
|
33
|
+
return if !fragments && !included_comment
|
|
34
|
+
|
|
35
|
+
out = []
|
|
36
|
+
Proscenium::Importer.each_stylesheet(delete: true) do |path, opts|
|
|
37
|
+
opts = opts[:css].is_a?(Hash) ? opts[:css] : {}
|
|
38
|
+
opts = opts.merge(data: opts[:data] || {})
|
|
39
|
+
opts = opts.merge(preload_links_header: false) if fragments
|
|
40
|
+
|
|
41
|
+
out << helpers.stylesheet_link_tag(path.delete_prefix('/'), extname: false, **opts)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
if fragments
|
|
45
|
+
response_body.first.prepend out.join.html_safe
|
|
46
|
+
elsif included_comment
|
|
47
|
+
response_body.first.gsub! CSS_COMMENT, out.join.html_safe
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def capture_and_replace_proscenium_javascripts
|
|
52
|
+
return if response_body.nil?
|
|
53
|
+
return if response_body.first.blank? || !Proscenium::Importer.js_imported?
|
|
54
|
+
|
|
55
|
+
included_comment = response_body.first.include?(JS_COMMENT)
|
|
56
|
+
fragments = proscenium_fragment_header&.split(/[,\s]+/)
|
|
57
|
+
|
|
58
|
+
return if !fragments && !included_comment
|
|
59
|
+
|
|
60
|
+
out = []
|
|
61
|
+
Proscenium::Importer.each_javascript(delete: true) do |path, opts|
|
|
62
|
+
next if opts.delete(:lazy)
|
|
63
|
+
|
|
64
|
+
opts = opts[:js].is_a?(Hash) ? opts[:js] : {}
|
|
65
|
+
opts = opts.merge(preload_links_header: false) if fragments
|
|
66
|
+
|
|
67
|
+
out << helpers.javascript_include_tag(path.delete_prefix('/'), extname: false, **opts)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if fragments
|
|
71
|
+
response_body.first.prepend out.join.html_safe
|
|
72
|
+
elsif included_comment
|
|
73
|
+
response_body.first.gsub! JS_COMMENT, out.join.html_safe
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Fragment rendering: phlex-rails 1.x used `X-Fragment`, phlex-rails 2.x uses `X-Fragments`.
|
|
80
|
+
# Honour both so side-loaded assets are injected into fragment responses regardless of the
|
|
81
|
+
# phlex-rails version in use. The value is only used as a presence flag (all imported assets
|
|
82
|
+
# are injected — Proscenium does not filter by fragment name), so the split delimiter is
|
|
83
|
+
# cosmetic; splitting on comma or whitespace covers both conventions (plural is comma-based).
|
|
84
|
+
def proscenium_fragment_header
|
|
85
|
+
request.headers['X-Fragment'] || request.headers['X-Fragments']
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
class << self
|
|
90
|
+
# Side loads assets for the class, and its super classes that respond to `.source_path`, which
|
|
91
|
+
# should return a Pathname of the class source file.
|
|
92
|
+
#
|
|
93
|
+
# Set the `abstract_class` class variable to true in any class, and it will not be side
|
|
94
|
+
# loaded.
|
|
95
|
+
#
|
|
96
|
+
# If the class responds to `.sideload`, it will be called after the regular side loading. You
|
|
97
|
+
# can use this to customise what is side loaded.
|
|
98
|
+
def sideload_inheritance_chain(obj, options)
|
|
99
|
+
return unless Proscenium.config.side_load
|
|
100
|
+
|
|
101
|
+
options = merge_options(options, obj.sideload_assets_options, obj)
|
|
102
|
+
|
|
103
|
+
css_imports = []
|
|
104
|
+
|
|
105
|
+
klass = obj.class
|
|
106
|
+
while klass.respond_to?(:source_path) && klass.source_path &&
|
|
107
|
+
(klass.respond_to?(:abstract_class) ? !klass.abstract_class : true)
|
|
108
|
+
if options[:css] == false
|
|
109
|
+
Importer.sideload klass.source_path, **options
|
|
110
|
+
else
|
|
111
|
+
Importer.sideload_js klass.source_path, **options
|
|
112
|
+
css_imports << klass.source_path
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
klass.sideload options if klass.respond_to?(:sideload)
|
|
116
|
+
|
|
117
|
+
klass = klass.superclass
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# All regular CSS files (*.css) are ancestrally sideloaded. However, the first CSS module
|
|
121
|
+
# in the ancestry is also sideloaded in addition to the regular CSS files. This is because
|
|
122
|
+
# the CSS module digest will be different for each file, so we only sideload the first CSS
|
|
123
|
+
# module.
|
|
124
|
+
css_imports.each do |it| # rubocop:disable Style/ItAssignment
|
|
125
|
+
break if Importer.sideload_css_module(it, **options).present?
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Sideload regular CSS files in reverse order.
|
|
129
|
+
#
|
|
130
|
+
# The reason why we sideload CSS after JS is because the order of CSS is important.
|
|
131
|
+
# Basically, the layout should be loaded before the view so that CSS cascading works in the
|
|
132
|
+
# right direction.
|
|
133
|
+
css_imports.reverse_each do |it| # rubocop:disable Style/ItAssignment
|
|
134
|
+
Importer.sideload_css it, **options
|
|
135
|
+
end
|
|
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
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Include this into any class to expose a `source_path` class and instance method, which will return
|
|
4
|
+
# the absolute file system path to the current object.
|
|
5
|
+
module Proscenium::SourcePath
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend ClassMethods
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def source_path
|
|
12
|
+
@source_path ||= name.nil? ? nil : Pathname.new(const_source_location(name).first)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
<header>
|
|
2
|
+
<h1>
|
|
3
|
+
<%= @exception_wrapper.exception_class_name %>
|
|
4
|
+
<% if params_valid? && @request.parameters['controller'] %>
|
|
5
|
+
in <%= @request.parameters['controller'].camelize %>Controller<% if @request.parameters['action'] %>#<%= @request.parameters['action'] %><% end %>
|
|
6
|
+
<% end %>
|
|
7
|
+
</h1>
|
|
8
|
+
</header>
|
|
9
|
+
|
|
10
|
+
<main role="main" id="container">
|
|
11
|
+
<%= render "rescues/message_and_suggestions", exception: @exception, exception_wrapper: @exception_wrapper %>
|
|
12
|
+
|
|
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']) %>
|
|
16
|
+
<div class="source">
|
|
17
|
+
<div class="data">
|
|
18
|
+
<pre>
|
|
19
|
+
|
|
20
|
+
<%= location['File'] %>:<%= location['Line'] %>:<%= location['Column'] %>
|
|
21
|
+
|
|
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) %>
|
|
25
|
+
<% else %> <%- end %>
|
|
26
|
+
</pre>
|
|
27
|
+
</div>
|
|
28
|
+
</div>
|
|
29
|
+
<% end %>
|
|
30
|
+
|
|
31
|
+
<%= render template: "rescues/_request_and_response" %>
|
|
32
|
+
</main>
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Proscenium
|
|
4
|
+
module Utils
|
|
5
|
+
module_function
|
|
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
|
+
|
|
21
|
+
# Returns a short digest for the given `value`, intended for CSS module class name suffixes.
|
|
22
|
+
#
|
|
23
|
+
# @param value [#to_s] The value to create the digest from. This will usually be the absolute
|
|
24
|
+
# file system file path.
|
|
25
|
+
# @return [String] digest of the given value.
|
|
26
|
+
def css_module_digest(value)
|
|
27
|
+
Digest::SHA1.hexdigest(value.to_s)[..7]
|
|
28
|
+
end
|
|
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
|
+
|
|
71
|
+
# Merges the given array of attribute `name`'s into the `kw_arguments`. A bang attribute is one
|
|
72
|
+
# that ends with an exclamation mark or - in Ruby parlance - a "bang", and has a boolean value.
|
|
73
|
+
# Modifies the given `kw_attributes`, and only attribute names in `allowed` will be merged.
|
|
74
|
+
#
|
|
75
|
+
# @param names [Array(Symbol)] of argument names
|
|
76
|
+
# @param kw_attributes [Hash] attributes to be merged with
|
|
77
|
+
# @param allowed [Array(Symbol)] attribute names allowed to be merged as bang attributes
|
|
78
|
+
#
|
|
79
|
+
# Example:
|
|
80
|
+
#
|
|
81
|
+
# def tab(name, *args, href:, **attributes)
|
|
82
|
+
# Hue::Utils.merge_bang_attributes!(args, attributes, [:current])
|
|
83
|
+
# end
|
|
84
|
+
#
|
|
85
|
+
# Allowing you to use either of the following API's:
|
|
86
|
+
#
|
|
87
|
+
# tab 'Tab 1', required: true
|
|
88
|
+
# tab 'Tab 1', :required!
|
|
89
|
+
#
|
|
90
|
+
def merge_bang_attributes!(names, kw_attributes, allowed)
|
|
91
|
+
allowed.each do |name|
|
|
92
|
+
sym_name = name.to_sym
|
|
93
|
+
bang_name = :"#{sym_name}!"
|
|
94
|
+
|
|
95
|
+
next unless names.include?(bang_name)
|
|
96
|
+
|
|
97
|
+
names.delete(bang_name)
|
|
98
|
+
|
|
99
|
+
# Keyword arguments should override the bang.
|
|
100
|
+
kw_attributes[sym_name] = true unless kw_attributes.key?(sym_name)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
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
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support'
|
|
4
|
+
require_relative 'proscenium/error'
|
|
5
|
+
|
|
6
|
+
module Proscenium
|
|
7
|
+
extend ActiveSupport::Autoload
|
|
8
|
+
|
|
9
|
+
# Default paths for Rails assets. Used by the `compute_asset_path` helper to maintain Rails
|
|
10
|
+
# default conventions of where JS and CSS files are located.
|
|
11
|
+
DEFAULT_RAILS_ASSET_PATHS = {
|
|
12
|
+
stylesheet: 'app/assets/stylesheets/',
|
|
13
|
+
javascript: 'app/javascript/'
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
FILE_EXTENSIONS = ['js', 'mjs', 'ts', 'jsx', 'tsx', 'css', 'js.map', 'mjs.map', 'jsx.map',
|
|
17
|
+
'ts.map', 'tsx.map', 'css.map'].freeze
|
|
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'
|
|
21
|
+
VENDOR_PATH_GLOB = '/vendor/**.{js,css}'
|
|
22
|
+
APP_PATH_GLOB = "/{#{ALLOWED_DIRECTORIES}}/**.{#{FILE_EXTENSIONS.join(',')}}".freeze
|
|
23
|
+
GEMS_PATH_GLOB = "/node_modules/@rubygems/**.{#{FILE_EXTENSIONS.join(',')}}".freeze
|
|
24
|
+
CHUNKS_PATH = %r{^/_asset_chunks/}
|
|
25
|
+
|
|
26
|
+
# Environment variables that should always be passed to the builder.
|
|
27
|
+
DEFAULT_ENV_VARS = Set['RAILS_ENV', 'NODE_ENV'].freeze
|
|
28
|
+
|
|
29
|
+
autoload :SourcePath
|
|
30
|
+
autoload :Utils
|
|
31
|
+
autoload :Monkey
|
|
32
|
+
autoload :Middleware
|
|
33
|
+
autoload :Manifest
|
|
34
|
+
autoload :EnsureLoaded
|
|
35
|
+
autoload :SideLoad
|
|
36
|
+
autoload :CssModule
|
|
37
|
+
autoload :Helper
|
|
38
|
+
autoload :Builder
|
|
39
|
+
autoload :Importer
|
|
40
|
+
autoload :Resolver
|
|
41
|
+
autoload :BundledGems
|
|
42
|
+
autoload :ContextMap
|
|
43
|
+
autoload :StaleContexts
|
|
44
|
+
autoload :MappingGeneration
|
|
45
|
+
|
|
46
|
+
class Deprecator
|
|
47
|
+
def deprecation_warning(name, message, _caller_backtrace = nil)
|
|
48
|
+
msg = "`#{name}` is deprecated and will be removed in a near future release of Proscenium"
|
|
49
|
+
msg << " (#{message})" if message
|
|
50
|
+
Kernel.warn msg
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
class MissingAssetError < Error
|
|
55
|
+
def initialize(path)
|
|
56
|
+
super
|
|
57
|
+
@path = path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def message
|
|
61
|
+
"The asset '#{@path}' was not found."
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
class PathResolutionFailed < Error
|
|
66
|
+
def initialize(path)
|
|
67
|
+
@path = path
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def message
|
|
72
|
+
"Path #{@path.inspect} cannot be resolved"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
def config
|
|
78
|
+
@config ||= Railtie.config.proscenium
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def root
|
|
82
|
+
Railtie.root
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
require 'proscenium/railtie'
|