proscenium 0.9.1-x86_64-darwin → 0.11.0.pre.1-x86_64-darwin

Sign up to get free protection for your applications and to get access to all the features.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +423 -63
  3. data/lib/proscenium/builder.rb +126 -0
  4. data/lib/proscenium/css_module/path.rb +31 -0
  5. data/lib/proscenium/css_module/transformer.rb +76 -0
  6. data/lib/proscenium/css_module.rb +6 -28
  7. data/lib/proscenium/ensure_loaded.rb +27 -0
  8. data/lib/proscenium/ext/proscenium +0 -0
  9. data/lib/proscenium/ext/proscenium.h +19 -12
  10. data/lib/proscenium/helper.rb +62 -0
  11. data/lib/proscenium/importer.rb +110 -0
  12. data/lib/proscenium/libs/react-manager/index.jsx +88 -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/log_subscriber.rb +1 -2
  16. data/lib/proscenium/middleware/base.rb +1 -1
  17. data/lib/proscenium/middleware/esbuild.rb +3 -5
  18. data/lib/proscenium/middleware.rb +7 -1
  19. data/lib/proscenium/{side_load/monkey.rb → monkey.rb} +16 -12
  20. data/lib/proscenium/phlex/{resolve_css_modules.rb → css_modules.rb} +6 -20
  21. data/lib/proscenium/phlex/page.rb +2 -2
  22. data/lib/proscenium/phlex/react_component.rb +27 -64
  23. data/lib/proscenium/phlex.rb +10 -29
  24. data/lib/proscenium/railtie.rb +20 -22
  25. data/lib/proscenium/react_componentable.rb +94 -0
  26. data/lib/proscenium/resolver.rb +37 -0
  27. data/lib/proscenium/side_load.rb +13 -72
  28. data/lib/proscenium/source_path.rb +15 -0
  29. data/lib/proscenium/utils.rb +13 -0
  30. data/lib/proscenium/version.rb +1 -1
  31. data/lib/proscenium/view_component/css_modules.rb +11 -0
  32. data/lib/proscenium/view_component/react_component.rb +15 -28
  33. data/lib/proscenium/view_component/sideload.rb +4 -0
  34. data/lib/proscenium/view_component.rb +8 -31
  35. data/lib/proscenium.rb +24 -68
  36. metadata +21 -58
  37. data/lib/proscenium/css_module/class_names_resolver.rb +0 -66
  38. data/lib/proscenium/css_module/resolver.rb +0 -76
  39. data/lib/proscenium/current.rb +0 -9
  40. data/lib/proscenium/esbuild/golib.rb +0 -97
  41. data/lib/proscenium/esbuild.rb +0 -32
  42. data/lib/proscenium/phlex/component_concerns.rb +0 -27
  43. data/lib/proscenium/side_load/ensure_loaded.rb +0 -25
  44. data/lib/proscenium/side_load/helper.rb +0 -25
  45. data/lib/proscenium/view_component/tag_builder.rb +0 -23
@@ -0,0 +1,83 @@
1
+ const controllerAttribute = "data-controller";
2
+ const controllerFilenameExtension = ".js";
3
+
4
+ export function lazyLoadControllersFrom(
5
+ under,
6
+ application,
7
+ element = document
8
+ ) {
9
+ lazyLoadExistingControllers(under, application, element);
10
+ lazyLoadNewControllers(under, application, element);
11
+ }
12
+
13
+ function lazyLoadExistingControllers(under, application, element) {
14
+ queryControllerNamesWithin(element).forEach((controllerName) =>
15
+ loadController(controllerName, under, application)
16
+ );
17
+ }
18
+
19
+ function lazyLoadNewControllers(under, application, element) {
20
+ new MutationObserver((mutationsList) => {
21
+ for (const { attributeName, target, type } of mutationsList) {
22
+ switch (type) {
23
+ case "attributes": {
24
+ if (
25
+ attributeName == controllerAttribute &&
26
+ target.getAttribute(controllerAttribute)
27
+ ) {
28
+ extractControllerNamesFrom(target).forEach((controllerName) =>
29
+ loadController(controllerName, under, application)
30
+ );
31
+ }
32
+ }
33
+
34
+ case "childList": {
35
+ lazyLoadExistingControllers(under, application, target);
36
+ }
37
+ }
38
+ }
39
+ }).observe(element, {
40
+ attributeFilter: [controllerAttribute],
41
+ subtree: true,
42
+ childList: true,
43
+ });
44
+ }
45
+
46
+ function queryControllerNamesWithin(element) {
47
+ return Array.from(element.querySelectorAll(`[${controllerAttribute}]`))
48
+ .map(extractControllerNamesFrom)
49
+ .flat();
50
+ }
51
+
52
+ function extractControllerNamesFrom(element) {
53
+ return element
54
+ .getAttribute(controllerAttribute)
55
+ .split(/\s+/)
56
+ .filter((content) => content.length);
57
+ }
58
+
59
+ function loadController(name, under, application) {
60
+ if (canRegisterController(name, application)) {
61
+ import(controllerFilename(name, under))
62
+ .then((module) => registerController(name, module, application))
63
+ .catch((error) =>
64
+ console.error(`Failed to autoload controller: ${name}`, error)
65
+ );
66
+ }
67
+ }
68
+
69
+ function controllerFilename(name, under) {
70
+ return `${under}/${name
71
+ .replace(/--/g, "/")
72
+ .replace(/-/g, "_")}_controller${controllerFilenameExtension}`;
73
+ }
74
+
75
+ function registerController(name, module, application) {
76
+ if (canRegisterController(name, application)) {
77
+ application.register(name, module.default);
78
+ }
79
+ }
80
+
81
+ function canRegisterController(name, application) {
82
+ return !application.router.modulesByIdentifier.has(name);
83
+ }
@@ -12,12 +12,11 @@ module Proscenium
12
12
 
13
13
  def build(event)
14
14
  path = event.payload[:identifier]
15
- path = path.start_with?(/https?%3A%2F%2F/) ? CGI.unescape(path) : path
15
+ path = CGI.unescape(path) if path.start_with?(/https?%3A%2F%2F/)
16
16
 
17
17
  info do
18
18
  message = +"[Proscenium] Building #{path}"
19
19
  message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})"
20
- message << "\n" if defined?(Rails.env) && Rails.env.development?
21
20
  end
22
21
  end
23
22
  end
@@ -51,7 +51,7 @@ module Proscenium
51
51
  end
52
52
 
53
53
  def file_readable?
54
- return unless (path = clean_path(sourcemap? ? real_path[0...-4] : real_path))
54
+ return false unless (path = clean_path(sourcemap? ? real_path[0...-4] : real_path))
55
55
 
56
56
  file_stat = File.stat(Pathname(root).join(path.delete_prefix('/').b).to_s)
57
57
  rescue SystemCallError
@@ -20,11 +20,9 @@ module Proscenium
20
20
  end
21
21
 
22
22
  def attempt
23
- ActiveSupport::Notifications.instrument('build.proscenium', identifier: path_to_build) do
24
- render_response Proscenium::Esbuild.build(path_to_build, root: root,
25
- base_url: @request.base_url)
26
- end
27
- rescue Proscenium::Esbuild::CompileError => e
23
+ render_response Proscenium::Builder.build(path_to_build, root: root,
24
+ base_url: @request.base_url)
25
+ rescue Proscenium::Builder::CompileError => e
28
26
  raise self.class::CompileError, { file: @request.fullpath, detail: e.message }, caller
29
27
  end
30
28
  end
@@ -13,12 +13,17 @@ module Proscenium
13
13
 
14
14
  def initialize(app)
15
15
  @app = app
16
+
17
+ chunks_path = Rails.public_path.join('assets').to_s
18
+ headers = Rails.application.config.public_file_server.headers || {}
19
+ @chunk_handler = ::ActionDispatch::FileHandler.new(chunks_path, headers: headers)
16
20
  end
17
21
 
18
22
  def call(env)
19
23
  request = Rack::Request.new(env)
20
24
 
21
25
  return @app.call(env) if !request.get? && !request.head?
26
+ return @chunk_handler.attempt(request.env) if request.path.match?(%r{^/_asset_chunks/})
22
27
 
23
28
  attempt(request) || @app.call(env)
24
29
  end
@@ -35,7 +40,8 @@ module Proscenium
35
40
 
36
41
  def find_type(request)
37
42
  return Url if request.path.match?(%r{^/https?%3A%2F%2F})
38
- return Esbuild if Pathname.new(request.path).fnmatch?(path_glob, File::FNM_EXTGLOB)
43
+
44
+ Esbuild if Pathname.new(request.path).fnmatch?(path_glob, File::FNM_EXTGLOB)
39
45
  end
40
46
 
41
47
  def path_glob
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Proscenium::SideLoad
3
+ module Proscenium
4
4
  # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
5
5
  module Monkey
6
6
  module TemplateRenderer
7
7
  private
8
8
 
9
9
  def render_template(view, template, layout_name, locals)
10
+ return super unless Proscenium.config.side_load
11
+
10
12
  layout = find_layout(layout_name, locals.keys, [formats.first])
11
13
  renderable = template.instance_variable_get(:@renderable)
12
14
 
@@ -14,20 +16,18 @@ class Proscenium::SideLoad
14
16
  template.is_a?(ActionView::Template::Renderable) &&
15
17
  renderable.class < ::ViewComponent::Base && renderable.class.format == :html
16
18
  # Side load controller rendered ViewComponent
17
- Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
18
- Proscenium::SideLoad.append "app/views/#{renderable.virtual_path}"
19
+ Importer.sideload "app/views/#{layout.virtual_path}" if layout
20
+ Importer.sideload "app/views/#{renderable.virtual_path}"
19
21
  elsif template.respond_to?(:virtual_path) &&
20
22
  template.respond_to?(:type) && template.type == :html
21
- # Side load regular view template.
22
- Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
23
+ Importer.sideload "app/views/#{layout.virtual_path}" if layout
23
24
 
24
25
  # Try side loading the variant template
25
26
  if template.respond_to?(:variant) && template.variant
26
- Proscenium::SideLoad.append "app/views/#{template.virtual_path}+#{template.variant}"
27
+ Importer.sideload "app/views/#{template.virtual_path}+#{template.variant}"
27
28
  end
28
29
 
29
- # The variant template may not exist (above), so we try the regular non-variant path.
30
- Proscenium::SideLoad.append "app/views/#{template.virtual_path}"
30
+ Importer.sideload "app/views/#{template.virtual_path}"
31
31
  end
32
32
 
33
33
  super
@@ -37,10 +37,14 @@ class Proscenium::SideLoad
37
37
  module PartialRenderer
38
38
  private
39
39
 
40
- def build_rendered_template(content, template)
41
- path = Rails.root.join('app', 'views', template.virtual_path)
42
- cssm = Proscenium::CssModule::Resolver.new(path)
43
- super cssm.compile_class_names(content), template
40
+ def render_partial_template(view, locals, template, layout, block)
41
+ if Proscenium.config.side_load && template.respond_to?(:virtual_path) &&
42
+ template.respond_to?(:type) && template.type == :html
43
+ Importer.sideload "app/views/#{layout.virtual_path}" if layout
44
+ Importer.sideload "app/views/#{template.virtual_path}"
45
+ end
46
+
47
+ super
44
48
  end
45
49
  end
46
50
  end
@@ -1,16 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Proscenium
4
- module Phlex::ResolveCssModules
5
- extend ActiveSupport::Concern
4
+ module Phlex::CssModules
5
+ include Proscenium::CssModule
6
6
 
7
- class_methods do
8
- attr_accessor :side_load_cache
9
- end
10
-
11
- def before_template
12
- self.class.side_load_cache ||= Set.new
13
- super
7
+ def self.included(base)
8
+ base.extend CssModule::Path
14
9
  end
15
10
 
16
11
  # Resolve and side load any CSS modules in the "class" attributes, where a CSS module is a class
@@ -44,24 +39,15 @@ module Proscenium
44
39
  # end
45
40
  # end
46
41
  #
47
- # The given class name should be underscored, and the resulting CSS module name will be
48
- # `camelCased` with a lower case first character.
49
- #
50
42
  # @raise [Proscenium::CssModule::Resolver::NotFound] If a CSS module file is not found for the
51
43
  # Phlex class file path.
52
44
  def process_attributes(**attributes)
53
45
  if attributes.key?(:class) && (attributes[:class] = tokens(attributes[:class])).include?('@')
54
- resolver = CssModule::ClassNamesResolver.new(attributes[:class], path)
55
- self.class.side_load_cache.merge resolver.stylesheets
56
- attributes[:class] = resolver.class_names
46
+ names = attributes[:class].is_a?(Array) ? attributes[:class] : attributes[:class].split
47
+ attributes[:class] = cssm.class_names(*names)
57
48
  end
58
49
 
59
50
  attributes
60
51
  end
61
-
62
- def after_template
63
- super
64
- self.class.side_load_cache&.each { |path| SideLoad.append! path, :css }
65
- end
66
52
  end
67
53
  end
@@ -32,7 +32,7 @@ module Proscenium::Phlex::Page
32
32
 
33
33
  def after_template
34
34
  super
35
- @_buffer.gsub!('<!-- [SIDE_LOAD_STYLESHEETS] -->', capture { side_load_stylesheets })
35
+ @_buffer.gsub!('<!-- [SIDE_LOAD_STYLESHEETS] -->', capture { include_stylesheets })
36
36
  end
37
37
 
38
38
  def page_title
@@ -56,7 +56,7 @@ module Proscenium::Phlex::Page
56
56
  super do
57
57
  yield if block_given?
58
58
 
59
- side_load_javascripts defer: true, type: :module
59
+ include_javascripts type: :module, defer: true
60
60
  end
61
61
  end
62
62
  end
@@ -1,69 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- #
4
- # Renders a div for use with @proscenium/component-manager.
5
- #
6
- # You can pass props to the component in the `:props` keyword argument.
7
- #
8
- # By default, the component is lazy loaded when intersecting using IntersectionObserver. Pass in
9
- # :lazy as false to disable this and render the component immediately.
10
- #
11
- # React components are not side loaded at all.
12
- #
13
- class Proscenium::Phlex::ReactComponent < Phlex::HTML
14
- class << self
15
- attr_accessor :path, :abstract_class
16
-
17
- def inherited(child)
18
- position = caller_locations(1, 1).first.label == 'inherited' ? 2 : 1
19
- child.path = Pathname.new caller_locations(position, 1).first.path.sub(/\.rb$/, '')
20
-
21
- super
3
+ module Proscenium
4
+ # Renders a <div> for use with React components, with data attributes specifying the component
5
+ # path and props.
6
+ #
7
+ # If a block is given, it will be yielded within the div, allowing for a custom "loading" UI. If
8
+ # no block is given, then a "loading..." text will be rendered. It is intended that the component
9
+ # is mounted to this div, and the loading UI will then be replaced with the component's rendered
10
+ # output.
11
+ #
12
+ # You can pass props to the component in the `:props` keyword argument.
13
+ class Phlex::ReactComponent < Phlex
14
+ self.abstract_class = true
15
+
16
+ include ReactComponentable
17
+
18
+ # Override this to provide your own loading UI.
19
+ #
20
+ # @example
21
+ # def template(**attributes, &block)
22
+ # super do
23
+ # 'Look at me! I am loading now...'
24
+ # end
25
+ # end
26
+ #
27
+ # @yield the given block to a `div` within the top level component div.
28
+ def template(**attributes, &block)
29
+ send root_tag, **{ data: data_attributes }.deep_merge(attributes), &block
22
30
  end
23
31
  end
24
-
25
- self.abstract_class = true
26
-
27
- include Proscenium::Phlex::ComponentConcerns::CssModules
28
-
29
- attr_writer :props, :lazy
30
-
31
- # @param props: [Hash]
32
- # @param lazy: [Boolean] Lazy load the component using IntersectionObserver. Default: true.
33
- def initialize(props: {}, lazy: true) # rubocop:disable Lint/MissingSuper
34
- @props = props
35
- @lazy = lazy
36
- end
37
-
38
- # @yield the given block to a `div` within the top level component div. If not given,
39
- # `<div>loading...</div>` will be rendered. Use this to display a loading UI while the component
40
- # is loading and rendered.
41
- def template(**attributes, &block)
42
- component_root(:div, **attributes, &block)
43
- end
44
-
45
- private
46
-
47
- def component_root(element, **attributes, &block)
48
- send element, data: { proscenium_component: component_data }, **attributes, &block
49
- end
50
-
51
- def props
52
- @props ||= {}
53
- end
54
-
55
- def lazy
56
- instance_variable_defined?(:@lazy) ? @lazy : (@lazy = false)
57
- end
58
-
59
- def component_data
60
- {
61
- path: virtual_path, lazy: lazy,
62
- props: props.deep_transform_keys { |k| k.to_s.camelize :lower }
63
- }.to_json
64
- end
65
-
66
- def virtual_path
67
- path.to_s.delete_prefix(Rails.root.to_s)
68
- end
69
32
  end
@@ -5,54 +5,35 @@ require 'phlex-rails'
5
5
  module Proscenium
6
6
  class Phlex < ::Phlex::HTML
7
7
  extend ActiveSupport::Autoload
8
- include Proscenium::CssModule
9
8
 
10
9
  autoload :Page
10
+ autoload :CssModules
11
11
  autoload :ReactComponent
12
- autoload :ResolveCssModules
13
- autoload :ComponentConcerns
14
12
 
15
13
  extend ::Phlex::Rails::HelperMacros
16
14
  include ::Phlex::Rails::Helpers::JavaScriptIncludeTag
17
15
  include ::Phlex::Rails::Helpers::StyleSheetLinkTag
16
+ include Proscenium::SourcePath
17
+ include CssModules
18
18
 
19
- define_output_helper :side_load_stylesheets
20
- define_output_helper :side_load_javascripts
19
+ define_output_helper :side_load_stylesheets # deprecated
20
+ define_output_helper :include_stylesheets
21
+ define_output_helper :side_load_javascripts # deprecated
22
+ define_output_helper :include_javascripts
21
23
 
22
- # Side loads the class, and its super classes that respond to `.path`. Assign the
23
- # `abstract_class` class variable to any abstract class, and it will not be side loaded.
24
- # Additionally, if the class responds to `side_load`, then that method is called.
25
24
  module Sideload
26
25
  def before_template
27
- klass = self.class
28
-
29
- if !klass.abstract_class && respond_to?(:side_load, true)
30
- side_load
31
- klass = klass.superclass
32
- end
33
-
34
- while !klass.abstract_class && klass.respond_to?(:path) && klass.path
35
- Proscenium::SideLoad.append klass.path
36
- klass = klass.superclass
37
- end
26
+ Proscenium::SideLoad.sideload_inheritance_chain self
38
27
 
39
28
  super
40
29
  end
41
30
  end
42
31
 
43
32
  class << self
44
- attr_accessor :path, :abstract_class
33
+ attr_accessor :abstract_class
45
34
 
46
35
  def inherited(child)
47
- unless child.path
48
- child.path = if caller_locations(1, 1).first.label == 'inherited'
49
- Pathname.new caller_locations(2, 1).first.path
50
- else
51
- Pathname.new caller_locations(1, 1).first.path
52
- end
53
- end
54
-
55
- child.prepend Sideload if Rails.application.config.proscenium.side_load
36
+ child.prepend Sideload
56
37
 
57
38
  super
58
39
  end
@@ -6,11 +6,6 @@ require 'proscenium/log_subscriber'
6
6
  ENV['RAILS_ENV'] = Rails.env
7
7
 
8
8
  module Proscenium
9
- FILE_EXTENSIONS = ['js', 'mjs', 'ts', 'jsx', 'tsx', 'css', 'js.map', 'mjs.map', 'jsx.map',
10
- 'ts.map', 'tsx.map', 'css.map'].freeze
11
-
12
- APPLICATION_INCLUDE_PATHS = ['config', 'app/assets', 'app/views', 'lib', 'node_modules'].freeze
13
-
14
9
  class << self
15
10
  def config
16
11
  @config ||= Railtie.config.proscenium
@@ -21,10 +16,19 @@ module Proscenium
21
16
  isolate_namespace Proscenium
22
17
 
23
18
  config.proscenium = ActiveSupport::OrderedOptions.new
19
+ config.proscenium.debug = false
24
20
  config.proscenium.side_load = true
21
+ config.proscenium.code_splitting = true
22
+ config.proscenium.include_paths = Set.new(APPLICATION_INCLUDE_PATHS)
23
+
24
+ # TODO: implement!
25
25
  config.proscenium.cache_query_string = Rails.env.production? && ENV.fetch('REVISION', nil)
26
26
  config.proscenium.cache_max_age = 2_592_000 # 30 days
27
- config.proscenium.include_paths = Set.new(APPLICATION_INCLUDE_PATHS)
27
+
28
+ # List of environment variable names that should be passed to the builder, which will then be
29
+ # passed to esbuild's `Define` option. Being explicit about which environment variables are
30
+ # defined means a faster build, as esbuild will have less to do.
31
+ config.proscenium.env_vars = Set.new
28
32
 
29
33
  # A hash of gems that can be side loaded. Assets from gems listed here can be side loaded.
30
34
  #
@@ -50,31 +54,25 @@ module Proscenium
50
54
  end
51
55
 
52
56
  initializer 'proscenium.middleware' do |app|
53
- app.middleware.insert_after ActionDispatch::Static, Proscenium::Middleware
57
+ app.middleware.insert_after ActionDispatch::Static, Middleware
54
58
  app.middleware.insert_after ActionDispatch::Static, Rack::ETag, 'no-cache'
55
59
  app.middleware.insert_after ActionDispatch::Static, Rack::ConditionalGet
56
60
  end
57
61
 
58
- initializer 'proscenium.side_loading' do |app|
59
- if app.config.proscenium.side_load
60
- Proscenium::Current.loaded ||= SideLoad::EXTENSIONS.to_h { |e| [e, Set.new] }
61
-
62
- ActiveSupport.on_load(:action_view) do
63
- ActionView::Base.include Proscenium::SideLoad::Helper
64
-
65
- ActionView::TemplateRenderer.prepend SideLoad::Monkey::TemplateRenderer
66
- ActionView::PartialRenderer.prepend SideLoad::Monkey::PartialRenderer
67
- end
68
-
69
- ActiveSupport.on_load(:action_controller) do
70
- ActionController::Base.include Proscenium::SideLoad::EnsureLoaded
71
- end
62
+ initializer 'proscenium.monkey_views' do
63
+ ActiveSupport.on_load(:action_view) do
64
+ ActionView::TemplateRenderer.prepend Monkey::TemplateRenderer
65
+ ActionView::PartialRenderer.prepend Monkey::PartialRenderer
72
66
  end
73
67
  end
74
68
 
75
69
  initializer 'proscenium.helper' do
76
70
  ActiveSupport.on_load(:action_view) do
77
- ActionView::Base.include Proscenium::Helper
71
+ ActionView::Base.include Helper
72
+ end
73
+
74
+ ActiveSupport.on_load(:action_controller) do
75
+ ActionController::Base.include EnsureLoaded
78
76
  end
79
77
  end
80
78
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module ReactComponentable
5
+ extend ActiveSupport::Concern
6
+
7
+ included do
8
+ # @return [Hash] the props to pass to the React component.
9
+ attr_writer :props
10
+
11
+ # The HTML tag to use as the wrapping element for the component. You can reassign this in your
12
+ # component class to use a different tag:
13
+ #
14
+ # class MyComponent < Proscenium::ViewComponent::ReactComponent
15
+ # self.root_tag = :span
16
+ # end
17
+ #
18
+ # @return [Symbol]
19
+ class_attribute :root_tag, instance_predicate: false, default: :div
20
+
21
+ # By default, the template block (content) of the component will be server rendered as normal.
22
+ # However, when React hydrates and takes control of the component, it's content will be
23
+ # replaced by React with the JavaScript rendered content. Enabling this option will forward
24
+ # the server rendered content as the `children` prop passed to the React component.
25
+ #
26
+ # @example
27
+ #
28
+ # const Component = ({ children }) => {
29
+ # return <div dangerouslySetInnerHTML={{ __html: children }} />
30
+ # }
31
+ #
32
+ # @return [Boolean]
33
+ class_attribute :forward_children, default: false
34
+
35
+ # Lazy load the component using IntersectionObserver?
36
+ #
37
+ # @return [Boolean]
38
+ class_attribute :lazy, default: false
39
+
40
+ class_attribute :loader
41
+ end
42
+
43
+ class_methods do
44
+ # Import only the component manager. The component itself is side loaded in the initializer,
45
+ # so that it can be lazy loaded based on the value of the `lazy` instance variable.
46
+ def sideload
47
+ Importer.import resolve: '@proscenium/react-manager/index.jsx'
48
+ Importer.sideload source_path, lazy: true
49
+ end
50
+ end
51
+
52
+ # @param props: [Hash]
53
+ def initialize(lazy: self.class.lazy, loader: self.class.loader, props: {})
54
+ self.lazy = lazy
55
+ self.loader = loader
56
+ @props = props
57
+ end
58
+
59
+ # The absolute URL path to the javascript component.
60
+ def virtual_path
61
+ @virtual_path ||= Resolver.resolve self.class.source_path.sub_ext('.jsx').to_s
62
+ end
63
+
64
+ def props
65
+ @props ||= {}
66
+ end
67
+
68
+ private
69
+
70
+ def data_attributes
71
+ {
72
+ proscenium_component_path: Pathname.new(virtual_path).to_s,
73
+ proscenium_component_props: prepared_props,
74
+ proscenium_component_lazy: lazy
75
+ }.tap do |x|
76
+ x[:proscenium_component_forward_children] = true if forward_children?
77
+ end
78
+ end
79
+
80
+ def prepared_props
81
+ props.deep_transform_keys do |term|
82
+ # This ensures that the first letter after a slash is not capitalized.
83
+ string = term.to_s.split('/').map { |str| str.camelize :lower }.join('/')
84
+
85
+ # Reverses the effect of ActiveSupport::Inflector.camelize converting slashes into `::`.
86
+ string.gsub '::', '/'
87
+ end.to_json
88
+ end
89
+
90
+ def loader_component
91
+ render Loader::Component.new(loader, @html_class, data_attributes, tag: @html_tag)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/current_attributes'
4
+
5
+ module Proscenium
6
+ class Resolver < ActiveSupport::CurrentAttributes
7
+ # TODO: cache this across requests in production.
8
+ attribute :resolved
9
+
10
+ # Resolve the given `path` to a URL path.
11
+ #
12
+ # @param path [String] Can be URL path, file system path, or bare specifier (ie. NPM package).
13
+ # @return [String] URL path.
14
+ def self.resolve(path) # rubocop:disable Metrics/AbcSize
15
+ self.resolved ||= {}
16
+
17
+ self.resolved[path] ||= begin
18
+ if path.start_with?('./', '../')
19
+ raise ArgumentError, 'path must be an absolute file system or URL path'
20
+ end
21
+
22
+ if (gem = Proscenium.config.side_load_gems.find { |_, x| path.start_with? "#{x[:root]}/" })
23
+ unless (package_name = gem[1][:package_name] || gem[0])
24
+ # TODO: manually resolve the path without esbuild
25
+ raise PathResolutionFailed, path
26
+ end
27
+
28
+ Builder.resolve "#{package_name}/#{path.delete_prefix("#{gem[1][:root]}/")}"
29
+ elsif path.start_with?("#{Rails.root}/")
30
+ path.delete_prefix Rails.root.to_s
31
+ else
32
+ Builder.resolve path
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end