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.
Files changed (71) hide show
  1. checksums.yaml +7 -0
  2. data/CODE_OF_CONDUCT.md +84 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +1020 -0
  5. data/exe/proscenium +19 -0
  6. data/exe/psm +5 -0
  7. data/lib/generators/proscenium/bun/bun_generator.rb +150 -0
  8. data/lib/generators/proscenium/bun/templates/proscenium.preload.js +61 -0
  9. data/lib/proscenium/app_package.rb +90 -0
  10. data/lib/proscenium/builder.rb +403 -0
  11. data/lib/proscenium/bundled_gems.rb +140 -0
  12. data/lib/proscenium/cli/brackets.rb +66 -0
  13. data/lib/proscenium/cli/bun_linker.rb +79 -0
  14. data/lib/proscenium/cli/capabilities.json +29 -0
  15. data/lib/proscenium/cli/collisions.rb +94 -0
  16. data/lib/proscenium/cli/contexts.rb +133 -0
  17. data/lib/proscenium/cli/diff.rb +80 -0
  18. data/lib/proscenium/cli/error.rb +291 -0
  19. data/lib/proscenium/cli/gem_archive.rb +44 -0
  20. data/lib/proscenium/cli/gem_check.rb +150 -0
  21. data/lib/proscenium/cli/gitignore.rb +63 -0
  22. data/lib/proscenium/cli/inspect.rb +257 -0
  23. data/lib/proscenium/cli/install.rb +378 -0
  24. data/lib/proscenium/cli/manager.rb +323 -0
  25. data/lib/proscenium/cli/platform_variants.rb +82 -0
  26. data/lib/proscenium/cli/pnpm_approvals.rb +109 -0
  27. data/lib/proscenium/cli/pnpm_workspace.rb +149 -0
  28. data/lib/proscenium/cli/project_lock.rb +106 -0
  29. data/lib/proscenium/cli/registration.rb +140 -0
  30. data/lib/proscenium/cli/reporter.rb +103 -0
  31. data/lib/proscenium/cli/rules.rb +169 -0
  32. data/lib/proscenium/cli/runner.rb +142 -0
  33. data/lib/proscenium/cli/verify.rb +107 -0
  34. data/lib/proscenium/cli.rb +122 -0
  35. data/lib/proscenium/context_map.rb +181 -0
  36. data/lib/proscenium/css_module/path.rb +32 -0
  37. data/lib/proscenium/css_module/transformer.rb +118 -0
  38. data/lib/proscenium/css_module.rb +63 -0
  39. data/lib/proscenium/dependency_context.rb +74 -0
  40. data/lib/proscenium/ensure_loaded.rb +31 -0
  41. data/lib/proscenium/error.rb +10 -0
  42. data/lib/proscenium/ext/proscenium +0 -0
  43. data/lib/proscenium/ext/proscenium.h +113 -0
  44. data/lib/proscenium/helper.rb +82 -0
  45. data/lib/proscenium/importer.rb +189 -0
  46. data/lib/proscenium/log_subscriber.rb +38 -0
  47. data/lib/proscenium/manifest.rb +48 -0
  48. data/lib/proscenium/mapping_generation.rb +64 -0
  49. data/lib/proscenium/middleware/base.rb +102 -0
  50. data/lib/proscenium/middleware/chunks.rb +46 -0
  51. data/lib/proscenium/middleware/esbuild.rb +11 -0
  52. data/lib/proscenium/middleware/ruby_gems.rb +37 -0
  53. data/lib/proscenium/middleware/silence_request.rb +31 -0
  54. data/lib/proscenium/middleware/vendor.rb +31 -0
  55. data/lib/proscenium/middleware.rb +67 -0
  56. data/lib/proscenium/monkey.rb +77 -0
  57. data/lib/proscenium/railtie.rb +106 -0
  58. data/lib/proscenium/railties/assets.rake +19 -0
  59. data/lib/proscenium/resolver.rb +67 -0
  60. data/lib/proscenium/runtime/bootstrap.js +266 -0
  61. data/lib/proscenium/runtime/bun.js +143 -0
  62. data/lib/proscenium/runtime/server.rb +702 -0
  63. data/lib/proscenium/side_load.rb +210 -0
  64. data/lib/proscenium/source_path.rb +15 -0
  65. data/lib/proscenium/stale_contexts.rb +118 -0
  66. data/lib/proscenium/templates/rescues/build_error.html.erb +32 -0
  67. data/lib/proscenium/utils.rb +104 -0
  68. data/lib/proscenium/version.rb +5 -0
  69. data/lib/proscenium/yaml_tree.rb +48 -0
  70. data/lib/proscenium.rb +87 -0
  71. 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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ VERSION = '0.26.0'
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 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'