proscenium 0.9.1-arm64-darwin → 0.11.0.pre.1-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 (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