proscenium 0.9.1-arm64-darwin → 0.11.0-arm64-darwin

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +451 -65
  3. data/lib/proscenium/builder.rb +144 -0
  4. data/lib/proscenium/css_module/path.rb +31 -0
  5. data/lib/proscenium/css_module/transformer.rb +82 -0
  6. data/lib/proscenium/css_module.rb +12 -25
  7. data/lib/proscenium/ensure_loaded.rb +27 -0
  8. data/lib/proscenium/ext/proscenium +0 -0
  9. data/lib/proscenium/ext/proscenium.h +20 -12
  10. data/lib/proscenium/helper.rb +85 -0
  11. data/lib/proscenium/importer.rb +110 -0
  12. data/lib/proscenium/libs/react-manager/index.jsx +101 -0
  13. data/lib/proscenium/libs/react-manager/react.js +2 -0
  14. data/lib/proscenium/libs/stimulus-loading.js +83 -0
  15. data/lib/proscenium/libs/test.js +1 -0
  16. data/lib/proscenium/log_subscriber.rb +1 -2
  17. data/lib/proscenium/middleware/base.rb +8 -8
  18. data/lib/proscenium/middleware/engines.rb +37 -0
  19. data/lib/proscenium/middleware/esbuild.rb +3 -5
  20. data/lib/proscenium/middleware/runtime.rb +18 -0
  21. data/lib/proscenium/middleware.rb +19 -4
  22. data/lib/proscenium/{side_load/monkey.rb → monkey.rb} +24 -12
  23. data/lib/proscenium/phlex/{resolve_css_modules.rb → css_modules.rb} +28 -16
  24. data/lib/proscenium/phlex/react_component.rb +27 -64
  25. data/lib/proscenium/phlex.rb +11 -30
  26. data/lib/proscenium/railtie.rb +49 -41
  27. data/lib/proscenium/react_componentable.rb +95 -0
  28. data/lib/proscenium/resolver.rb +37 -0
  29. data/lib/proscenium/side_load.rb +13 -72
  30. data/lib/proscenium/source_path.rb +15 -0
  31. data/lib/proscenium/templates/rescues/build_error.html.erb +30 -0
  32. data/lib/proscenium/utils.rb +13 -0
  33. data/lib/proscenium/version.rb +1 -1
  34. data/lib/proscenium/view_component/css_modules.rb +11 -0
  35. data/lib/proscenium/view_component/react_component.rb +15 -28
  36. data/lib/proscenium/view_component/sideload.rb +4 -0
  37. data/lib/proscenium/view_component.rb +8 -31
  38. data/lib/proscenium.rb +23 -68
  39. metadata +25 -59
  40. data/lib/proscenium/css_module/class_names_resolver.rb +0 -66
  41. data/lib/proscenium/css_module/resolver.rb +0 -76
  42. data/lib/proscenium/current.rb +0 -9
  43. data/lib/proscenium/esbuild/golib.rb +0 -97
  44. data/lib/proscenium/esbuild.rb +0 -32
  45. data/lib/proscenium/phlex/component_concerns.rb +0 -27
  46. data/lib/proscenium/phlex/page.rb +0 -62
  47. data/lib/proscenium/side_load/ensure_loaded.rb +0 -25
  48. data/lib/proscenium/side_load/helper.rb +0 -25
  49. data/lib/proscenium/view_component/tag_builder.rb +0 -23
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+ require 'oj'
5
+
6
+ module Proscenium
7
+ class Builder
8
+ class CompileError < StandardError; end
9
+
10
+ class Result < FFI::Struct
11
+ layout :success, :bool,
12
+ :response, :string
13
+ end
14
+
15
+ module Request
16
+ extend FFI::Library
17
+ ffi_lib Pathname.new(__dir__).join('ext/proscenium').to_s
18
+
19
+ enum :environment, [:development, 1, :test, :production]
20
+
21
+ attach_function :build, [
22
+ :string, # path or entry point. multiple can be given by separating with a semi-colon
23
+ :string, # base URL of the Rails app. eg. https://example.com
24
+ :string, # path to import map, relative to root
25
+ :string, # ENV variables as a JSON string
26
+
27
+ # Config
28
+ :string, # Rails application root
29
+ :string, # Proscenium gem root
30
+ :environment, # Rails environment as a Symbol
31
+ :bool, # code splitting enabled?
32
+ :string, # engine names and paths as a JSON string
33
+ :bool # debugging enabled?
34
+ ], Result.by_value
35
+
36
+ attach_function :resolve, [
37
+ :string, # path or entry point
38
+ :string, # path to import map, relative to root
39
+
40
+ # Config
41
+ :string, # Rails application root
42
+ :string, # Proscenium gem root
43
+ :environment, # Rails environment as a Symbol
44
+ :bool # debugging enabled?
45
+ ], Result.by_value
46
+ end
47
+
48
+ class BuildError < StandardError
49
+ attr_reader :error
50
+
51
+ def initialize(error)
52
+ @error = Oj.load(error, mode: :strict).deep_transform_keys(&:underscore)
53
+
54
+ msg = @error['text']
55
+ if (location = @error['location'])
56
+ msg << " at #{location['file']}:#{location['line']}:#{location['column']}"
57
+ end
58
+
59
+ super msg
60
+ end
61
+ end
62
+
63
+ class ResolveError < StandardError
64
+ attr_reader :error_msg, :path
65
+
66
+ def initialize(path, error_msg)
67
+ super "Failed to resolve '#{path}' -- #{error_msg}"
68
+ end
69
+ end
70
+
71
+ def self.build(path, root: nil, base_url: nil)
72
+ new(root: root, base_url: base_url).build(path)
73
+ end
74
+
75
+ def self.resolve(path, root: nil)
76
+ new(root: root).resolve(path)
77
+ end
78
+
79
+ def initialize(root: nil, base_url: nil)
80
+ @root = root || Rails.root
81
+ @base_url = base_url
82
+ end
83
+
84
+ def build(path) # rubocop:disable Metrics/AbcSize
85
+ ActiveSupport::Notifications.instrument('build.proscenium', identifier: path) do
86
+ result = Request.build(path, @base_url, import_map, env_vars.to_json,
87
+ @root.to_s,
88
+ Pathname.new(__dir__).join('..', '..').to_s,
89
+ Rails.env.to_sym,
90
+ Proscenium.config.code_splitting,
91
+ engines.to_json,
92
+ Proscenium.config.debug)
93
+
94
+ raise BuildError, result[:response] unless result[:success]
95
+
96
+ result[:response]
97
+ end
98
+ end
99
+
100
+ def resolve(path)
101
+ ActiveSupport::Notifications.instrument('resolve.proscenium', identifier: path) do
102
+ result = Request.resolve(path, import_map, @root.to_s,
103
+ Pathname.new(__dir__).join('..', '..').to_s,
104
+ Rails.env.to_sym,
105
+ Proscenium.config.debug)
106
+ raise ResolveError.new(path, result[:response]) unless result[:success]
107
+
108
+ result[:response]
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # Build the ENV variables as determined by `Proscenium.config.env_vars` and
115
+ # `Proscenium::DEFAULT_ENV_VARS` to pass to esbuild.
116
+ def env_vars
117
+ ENV['NODE_ENV'] = ENV.fetch('RAILS_ENV', nil)
118
+ ENV.slice(*Proscenium.config.env_vars + Proscenium::DEFAULT_ENV_VARS)
119
+ end
120
+
121
+ def cache_query_string
122
+ q = Proscenium.config.cache_query_string
123
+ q ? "--cache-query-string #{q}" : nil
124
+ end
125
+
126
+ def engines
127
+ Proscenium.config.engines.to_h { |e| [e.engine_name, e.root.to_s] }
128
+ end
129
+
130
+ def import_map
131
+ return unless (path = Rails.root&.join('config'))
132
+
133
+ if (json = path.join('import_map.json')).exist?
134
+ return json.relative_path_from(@root).to_s
135
+ end
136
+
137
+ if (js = path.join('import_map.js')).exist?
138
+ return js.relative_path_from(@root).to_s
139
+ end
140
+
141
+ nil
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module CssModule::Path
5
+ # Returns the path to the CSS module file for this class, where the file is located alongside
6
+ # the class file, and has the same name as the class file, but with a `.module.css` extension.
7
+ #
8
+ # If the CSS module file does not exist, it's ancestry is checked, returning the first that
9
+ # exists. Then finally `nil` is returned if never found.
10
+ #
11
+ # @return [Pathname]
12
+ def css_module_path
13
+ return @css_module_path if instance_variable_defined?(:@css_module_path)
14
+
15
+ path = source_path.sub_ext('.module.css')
16
+ @css_module_path = path.exist? ? path : nil
17
+
18
+ unless @css_module_path
19
+ klass = superclass
20
+
21
+ while klass.respond_to?(:css_module_path) && !klass.abstract_class
22
+ break if (@css_module_path = klass.css_module_path)
23
+
24
+ klass = klass.superclass
25
+ end
26
+ end
27
+
28
+ @css_module_path
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class CssModule::Transformer
5
+ FILE_EXT = '.module.css'
6
+
7
+ def self.class_names(path, *names)
8
+ new(path).class_names(*names)
9
+ end
10
+
11
+ def initialize(source_path)
12
+ return unless (@source_path = source_path)
13
+
14
+ @source_path = Pathname.new(@source_path) unless @source_path.is_a?(Pathname)
15
+ @source_path = @source_path.sub_ext(FILE_EXT) unless @source_path.to_s.end_with?(FILE_EXT)
16
+ end
17
+
18
+ # Transform each of the given class `names` to their respective CSS module name, which consist
19
+ # of the name, and suffixed with the digest of the resolved source path.
20
+ #
21
+ # Any name beginning with '@' will be transformed to a CSS module name. If `require_prefix` is
22
+ # false, then all names will be transformed to a CSS module name regardless of whether or not
23
+ # they begin with '@'.
24
+ #
25
+ # class_names :@my_module_name, :my_class_name
26
+ #
27
+ # Note that the generated digest is based on the resolved (URL) path, not the original path.
28
+ #
29
+ # You can also provide a path specifier and class name. The path will be the URL path to a
30
+ # stylesheet. The class name will be the name of the class to transform.
31
+ #
32
+ # class_names "/lib/button@default"
33
+ # class_names "mypackage/button@large"
34
+ # class_names "@scoped/package/button@small"
35
+ #
36
+ # @param names [String,Symbol,Array<String,Symbol>]
37
+ # @param require_prefix: [Boolean] whether or not to require the `@` prefix.
38
+ # @return [Array<String>] the transformed CSS module names.
39
+ def class_names(*names, require_prefix: true)
40
+ names.map do |name|
41
+ original_name = name.dup
42
+ name = name.to_s if name.is_a?(Symbol)
43
+
44
+ if name.include?('/')
45
+ if name.start_with?('@')
46
+ # Scoped bare specifier (eg. "@scoped/package/lib/button@default").
47
+ _, path, name = name.split('@')
48
+ path = "@#{path}"
49
+ else
50
+ # Local path (eg. /some/path/to/button@default") or bare specifier (eg.
51
+ # "mypackage/lib/button@default").
52
+ path, name = name.split('@')
53
+ end
54
+
55
+ class_name! name, original_name, path: "#{path}#{FILE_EXT}"
56
+ elsif name.start_with?('@')
57
+ class_name! name[1..], original_name
58
+ else
59
+ require_prefix ? name : class_name!(name, original_name)
60
+ end
61
+ end
62
+ end
63
+
64
+ def class_name!(name, original_name, path: @source_path)
65
+ unless path
66
+ raise Proscenium::CssModule::TransformError.new(original_name, 'CSS module path not given')
67
+ end
68
+
69
+ resolved_path = Resolver.resolve(path.to_s)
70
+ digest = Importer.import(resolved_path)
71
+
72
+ transformed_name = name.to_s
73
+ transformed_name = if transformed_name.start_with?('_')
74
+ "_#{transformed_name[1..]}-#{digest}"
75
+ else
76
+ "#{transformed_name}-#{digest}"
77
+ end
78
+
79
+ [transformed_name, resolved_path]
80
+ end
81
+ end
82
+ end
@@ -3,41 +3,28 @@
3
3
  module Proscenium::CssModule
4
4
  extend ActiveSupport::Autoload
5
5
 
6
- class StylesheetNotFound < StandardError
7
- def initialize(pathname)
8
- @pathname = pathname
9
- super
10
- end
11
-
12
- def message
13
- "Stylesheet is required, but does not exist: #{@pathname}"
14
- end
15
- end
6
+ autoload :Path
7
+ autoload :Transformer
16
8
 
17
- autoload :ClassNamesResolver
18
- autoload :Resolver # deprecated
9
+ class TransformError < StandardError
10
+ def initialize(name, additional_msg = nil)
11
+ msg = "Failed to transform CSS module `#{name}`"
12
+ msg << ' - ' << additional_msg if additional_msg
19
13
 
20
- # Like `css_modules`, but will raise if the stylesheet cannot be found.
21
- #
22
- # @param name [Array, String]
23
- def css_module!(names)
24
- cssm.class_names!(names).join ' '
14
+ super msg
15
+ end
25
16
  end
26
17
 
27
18
  # Accepts one or more CSS class names, and transforms them into CSS module names.
28
19
  #
29
- # @param name [Array, String]
30
- def css_module(names)
31
- cssm.class_names(names).join ' '
20
+ # @param name [String,Symbol,Array<String,Symbol>]
21
+ def css_module(*names)
22
+ cssm.class_names(*names, require_prefix: false).map { |name, _| name }.join(' ')
32
23
  end
33
24
 
34
25
  private
35
26
 
36
- def path
37
- self.class.path
38
- end
39
-
40
27
  def cssm
41
- @cssm ||= Resolver.new(path)
28
+ @cssm ||= Transformer.new(self.class.css_module_path)
42
29
  end
43
30
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ NotIncludedError = Class.new(StandardError)
5
+
6
+ module EnsureLoaded
7
+ def self.included(child)
8
+ child.class_eval do
9
+ append_after_action do
10
+ if request.format.html? && Importer.imported?
11
+ if Importer.js_imported?
12
+ raise NotIncludedError, 'There are javascripts to be included, but they have ' \
13
+ 'not been included in the page. Did you forget to add the ' \
14
+ '`#include_javascripts` helper in your views?'
15
+ end
16
+
17
+ if Importer.css_imported?
18
+ raise NotIncludedError, 'There are stylesheets to be included, but they have ' \
19
+ 'not been included in the page. Did you forget to add the ' \
20
+ '`#include_stylesheets` helper in your views?'
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
Binary file
@@ -85,23 +85,31 @@ extern "C" {
85
85
 
86
86
  // Build the given `path` in the `root`.
87
87
  //
88
- // - path - The path to build relative to `root`.
89
- // - root - The working directory.
90
- // - baseUrl - base URL of the Rails app. eg. https://example.com
91
- // - env - The environment (1 = development, 2 = test, 3 = production)
92
- // - importMap - Path to the import map relative to `root`.
93
- // - debug
88
+ // BuildOptions
89
+ // - path - The path to build relative to `root`. Multiple paths can be given by separating them
90
+ // with a semi-colon.
91
+ // - baseUrl - base URL of the Rails app. eg. https://example.com
92
+ // - importMap - Path to the import map relative to `root`.
93
+ // - envVars - JSON string of environment variables.
94
+ // Config:
95
+ // - root - The working directory.
96
+ // - env - The environment (1 = development, 2 = test, 3 = production)
97
+ // - codeSpitting?
98
+ // - debug?
94
99
  //
95
- extern struct Result build(char* filepath, char* root, char* baseUrl, unsigned int env, char* importMap, GoUint8 debug);
100
+ extern struct Result build(char* filepath, char* baseUrl, char* importMap, char* envVars, char* appRoot, char* gemPath, unsigned int env, GoUint8 codeSplitting, char* engines, GoUint8 debug);
96
101
 
97
102
  // Resolve the given `path` relative to the `root`.
98
103
  //
99
- // - path - The path to build relative to `root`.
100
- // - root - The working directory.
101
- // - env - The environment (1 = development, 2 = test, 3 = production)
102
- // - importMap - Path to the import map relative to `root`.
104
+ // ResolveOptions
105
+ // - path - The path to build relative to `root`.
106
+ // - importMap - Path to the import map relative to `root`.
107
+ // Config
108
+ // - root - The working directory.
109
+ // - env - The environment (1 = development, 2 = test, 3 = production)
110
+ // - debug?
103
111
  //
104
- extern struct Result resolve(char* path, char* root, unsigned int env, char* importMap);
112
+ extern struct Result resolve(char* path, char* importMap, char* appRoot, char* gemPath, unsigned int env, GoUint8 debug);
105
113
 
106
114
  #ifdef __cplusplus
107
115
  }
@@ -15,5 +15,90 @@ module Proscenium
15
15
 
16
16
  super
17
17
  end
18
+
19
+ # Accepts one or more CSS class names, and transforms them into CSS module names.
20
+ #
21
+ # @see CssModule::Transformer#class_names
22
+ # @param name [String,Symbol,Array<String,Symbol>]
23
+ def css_module(*names)
24
+ path = Pathname.new(@lookup_context.find(@virtual_path).identifier).sub_ext('')
25
+ CssModule::Transformer.new(path).class_names(*names, require_prefix: false).map do |name, _|
26
+ name
27
+ end.join(' ')
28
+ end
29
+
30
+ def include_stylesheets(**options)
31
+ out = []
32
+ Importer.each_stylesheet(delete: true) do |path, _path_options|
33
+ out << stylesheet_link_tag(path, extname: false, **options)
34
+ end
35
+ out.join("\n").html_safe
36
+ end
37
+ alias side_load_stylesheets include_stylesheets
38
+ deprecate side_load_stylesheets: 'Use `include_stylesheets` instead', deprecator: Deprecator.new
39
+
40
+ # Includes all javascripts that have been imported and side loaded.
41
+ #
42
+ # @param extract_lazy_scripts [Boolean] if true, any lazy scripts will be extracted using
43
+ # `content_for` to `:proscenium_lazy_scripts` for later use. Be sure to include this in your
44
+ # page with the `declare_lazy_scripts` helper, or simply
45
+ # `content_for :proscenium_lazy_scripts`.
46
+ # @return [String] the HTML tags for the javascripts.
47
+ def include_javascripts(extract_lazy_scripts: false, **options) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
48
+ out = []
49
+
50
+ if Rails.application.config.proscenium.code_splitting && Importer.multiple_js_imported?
51
+ imports = Importer.imported.dup
52
+
53
+ paths_to_build = []
54
+ Importer.each_javascript(delete: true) do |x, _|
55
+ paths_to_build << x.delete_prefix('/')
56
+ end
57
+
58
+ result = Builder.build(paths_to_build.join(';'), base_url: request.base_url)
59
+
60
+ # Remove the react components from the results, so they are not side loaded. Instead they
61
+ # are lazy loaded by the component manager.
62
+
63
+ scripts = {}
64
+ result.split(';').each do |x|
65
+ inpath, outpath = x.split('::')
66
+ inpath.prepend '/'
67
+ outpath.delete_prefix! 'public'
68
+
69
+ next unless imports.key?(inpath)
70
+
71
+ if (import = imports[inpath]).delete(:lazy)
72
+ scripts[inpath] = import.merge(outpath: outpath)
73
+ else
74
+ out << javascript_include_tag(outpath, extname: false, **options)
75
+ end
76
+ end
77
+
78
+ if extract_lazy_scripts
79
+ content_for :proscenium_lazy_scripts do
80
+ tag.script type: 'application/json', id: 'prosceniumLazyScripts' do
81
+ raw scripts.to_json
82
+ end
83
+ end
84
+ else
85
+ out << tag.script(type: 'application/json', id: 'prosceniumLazyScripts') do
86
+ raw scripts.to_json
87
+ end
88
+ end
89
+ else
90
+ Importer.each_javascript(delete: true) do |path, _|
91
+ out << javascript_include_tag(path, extname: false, **options)
92
+ end
93
+ end
94
+
95
+ out.join("\n").html_safe
96
+ end
97
+ alias side_load_javascripts include_javascripts
98
+ deprecate side_load_javascripts: 'Use `include_javascripts` instead', deprecator: Deprecator.new
99
+
100
+ def declare_lazy_scripts
101
+ content_for :proscenium_lazy_scripts
102
+ end
18
103
  end
19
104
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/current_attributes'
4
+
5
+ module Proscenium
6
+ class Importer < ActiveSupport::CurrentAttributes
7
+ JS_EXTENSIONS = %w[.tsx .ts .jsx .js].freeze
8
+ CSS_EXTENSIONS = %w[.module.css .css].freeze
9
+
10
+ # Holds the JS and CSS files to include in the current request.
11
+ #
12
+ # Example:
13
+ # {
14
+ # '/path/to/input/file.js': {
15
+ # output: '/path/to/compiled/file.js',
16
+ # **options
17
+ # }
18
+ # }
19
+ attribute :imported
20
+
21
+ class << self
22
+ # Import the given `filepath`. This is idempotent - it will never include duplicates.
23
+ #
24
+ # @param filepath [String] Absolute URL path (relative to Rails root) of the file to import.
25
+ # Should be the actual asset file, eg. app.css, some/component.js.
26
+ # @param resolve [String] description of the file to resolve and import.
27
+ # @return [String] the digest of the imported file path if a css module (*.module.css).
28
+ def import(filepath = nil, resolve: nil, **options)
29
+ self.imported ||= {}
30
+
31
+ filepath = Resolver.resolve(resolve) if !filepath && resolve
32
+ css_module = filepath.end_with?('.module.css')
33
+
34
+ unless self.imported.key?(filepath)
35
+ # ActiveSupport::Notifications.instrument('sideload.proscenium', identifier: value)
36
+
37
+ self.imported[filepath] = { **options }
38
+ self.imported[filepath][:digest] = Utils.digest(filepath) if css_module
39
+ end
40
+
41
+ css_module ? self.imported[filepath][:digest] : nil
42
+ end
43
+
44
+ # Sideloads JS and CSS assets for the given Ruby filepath.
45
+ #
46
+ # Any files with the same base name and matching a supported extension will be sideloaded.
47
+ # Only one JS and one CSS file will be sideloaded, with the first match used in the following
48
+ # order:
49
+ # - JS extensions: .tsx, .ts, .jsx, and .js.
50
+ # - CSS extensions: .css.module, and .css.
51
+ #
52
+ # Example:
53
+ # - `app/views/layouts/application.rb`
54
+ # - `app/views/layouts/application.css`
55
+ # - `app/views/layouts/application.js`
56
+ # - `app/views/layouts/application.tsx`
57
+ #
58
+ # A request to sideload `app/views/layouts/application.rb` will result in `application.css`
59
+ # and `application.tsx` being sideloaded. `application.js` will not be sideloaded because the
60
+ # `.tsx` extension is matched first.
61
+ #
62
+ # @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
63
+ def sideload(filepath, **options)
64
+ return unless Proscenium.config.side_load
65
+
66
+ filepath = Rails.root.join(filepath) unless filepath.is_a?(Pathname)
67
+ filepath = filepath.sub_ext('')
68
+
69
+ import_if_exists = lambda do |x|
70
+ if (fp = filepath.sub_ext(x)).exist?
71
+ import(Resolver.resolve(fp.to_s), sideloaded: true, **options)
72
+ end
73
+ end
74
+
75
+ JS_EXTENSIONS.find(&import_if_exists)
76
+ CSS_EXTENSIONS.find(&import_if_exists)
77
+ end
78
+
79
+ def each_stylesheet(delete: false)
80
+ return if imported.blank?
81
+
82
+ blk = proc { |key, options| key.end_with?(*CSS_EXTENSIONS) && yield(key, options) }
83
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
84
+ end
85
+
86
+ def each_javascript(delete: false)
87
+ return if imported.blank?
88
+
89
+ blk = proc { |key, options| key.end_with?(*JS_EXTENSIONS) && yield(key, options) }
90
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
91
+ end
92
+
93
+ def css_imported?
94
+ imported&.keys&.any? { |x| x.end_with?(*CSS_EXTENSIONS) }
95
+ end
96
+
97
+ def js_imported?
98
+ imported&.keys&.any? { |x| x.end_with?(*JS_EXTENSIONS) }
99
+ end
100
+
101
+ def multiple_js_imported?
102
+ imported&.keys&.many? { |x| x.end_with?(*JS_EXTENSIONS) }
103
+ end
104
+
105
+ def imported?(filepath = nil)
106
+ filepath ? imported&.key?(filepath) : !imported.blank?
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,101 @@
1
+ window.Proscenium = window.Proscenium || { lazyScripts: {} };
2
+
3
+ const element = document.querySelector("#prosceniumLazyScripts");
4
+ if (element) {
5
+ const scripts = JSON.parse(element.text);
6
+ window.Proscenium.lazyScripts = {
7
+ ...window.Proscenium.lazyScripts,
8
+ ...scripts,
9
+ };
10
+ }
11
+
12
+ const elements = document.querySelectorAll("[data-proscenium-component-path]");
13
+ elements.length > 0 && init(elements);
14
+
15
+ function init() {
16
+ /**
17
+ * Mounts component located at `path`, into the DOM `element`.
18
+ *
19
+ * The element at which the component is mounted must have the following data attributes:
20
+ *
21
+ * - `data-proscenium-component-path`: The URL path to the component's source file.
22
+ * - `data-proscenium-component-props`: JSON object of props to pass to the component.
23
+ * - `data-proscenium-component-lazy`: If present, will lazily load the component when in view
24
+ * using IntersectionObserver.
25
+ * - `data-proscenium-component-forward-children`: If the element should forward its `innerHTML`
26
+ * as the component's children prop.
27
+ */
28
+ function mount(element, path, { children, ...props }) {
29
+ // For testing and simulation of slow connections.
30
+ // const sim = new Promise((resolve) => setTimeout(resolve, 5000));
31
+
32
+ if (!window.Proscenium.lazyScripts[path]) {
33
+ throw `[proscenium/react/manager] Cannot load component ${path} (not found in Proscenium.lazyScripts)`;
34
+ }
35
+
36
+ const react = import("@proscenium/react-manager/react");
37
+ const Component = import(window.Proscenium.lazyScripts[path].outpath);
38
+
39
+ const forwardChildren =
40
+ "prosceniumComponentForwardChildren" in element.dataset &&
41
+ element.innerHTML !== "";
42
+
43
+ Promise.all([react, Component])
44
+ .then(([r, c]) => {
45
+ if (proscenium.env.RAILS_ENV === "development") {
46
+ console.groupCollapsed(
47
+ `[proscenium/react/manager] 🔥 %o mounted!`,
48
+ path
49
+ );
50
+ console.log("props: %o", props);
51
+ console.groupEnd();
52
+ }
53
+
54
+ let component;
55
+ if (forwardChildren) {
56
+ component = r.createElement(c.default, props, element.innerHTML);
57
+ } else if (children) {
58
+ component = r.createElement(c.default, props, children);
59
+ } else {
60
+ component = r.createElement(c.default, props);
61
+ }
62
+
63
+ r.createRoot(element).render(component);
64
+ })
65
+ .catch((error) => {
66
+ console.error("[proscenium/react/manager] %o - %o", path, error);
67
+ });
68
+ }
69
+
70
+ Array.from(elements, (element) => {
71
+ const path = element.dataset.prosceniumComponentPath;
72
+ const isLazy = "prosceniumComponentLazy" in element.dataset;
73
+ const props = JSON.parse(element.dataset.prosceniumComponentProps);
74
+
75
+ if (proscenium.env.RAILS_ENV === "development") {
76
+ console.groupCollapsed(
77
+ `[proscenium/react/manager] ${isLazy ? "💤" : "⚡️"} %o`,
78
+ path
79
+ );
80
+ console.log("element: %o", element);
81
+ console.log("props: %o", props);
82
+ console.groupEnd();
83
+ }
84
+
85
+ if (isLazy) {
86
+ const observer = new IntersectionObserver((entries) => {
87
+ entries.forEach((entry) => {
88
+ if (entry.isIntersecting) {
89
+ observer.unobserve(element);
90
+
91
+ mount(element, path, props);
92
+ }
93
+ });
94
+ });
95
+
96
+ observer.observe(element);
97
+ } else {
98
+ mount(element, path, props);
99
+ }
100
+ });
101
+ }