proscenium 0.16.0

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 (59) 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 +908 -0
  5. data/lib/proscenium/builder.rb +189 -0
  6. data/lib/proscenium/core_ext/object/css_module_ivars.rb +19 -0
  7. data/lib/proscenium/css_module/path.rb +31 -0
  8. data/lib/proscenium/css_module/rewriter.rb +44 -0
  9. data/lib/proscenium/css_module/transformer.rb +84 -0
  10. data/lib/proscenium/css_module.rb +57 -0
  11. data/lib/proscenium/ensure_loaded.rb +27 -0
  12. data/lib/proscenium/ext/proscenium +0 -0
  13. data/lib/proscenium/ext/proscenium.h +131 -0
  14. data/lib/proscenium/helper.rb +70 -0
  15. data/lib/proscenium/importer.rb +134 -0
  16. data/lib/proscenium/libs/custom_element.js +54 -0
  17. data/lib/proscenium/libs/react-manager/index.jsx +121 -0
  18. data/lib/proscenium/libs/react-manager/react.js +2 -0
  19. data/lib/proscenium/libs/stimulus-loading.js +65 -0
  20. data/lib/proscenium/libs/test.js +1 -0
  21. data/lib/proscenium/libs/ujs/class.js +15 -0
  22. data/lib/proscenium/libs/ujs/data_confirm.js +23 -0
  23. data/lib/proscenium/libs/ujs/data_disable_with.js +68 -0
  24. data/lib/proscenium/libs/ujs/index.js +9 -0
  25. data/lib/proscenium/log_subscriber.rb +37 -0
  26. data/lib/proscenium/middleware/base.rb +103 -0
  27. data/lib/proscenium/middleware/engines.rb +45 -0
  28. data/lib/proscenium/middleware/esbuild.rb +30 -0
  29. data/lib/proscenium/middleware/runtime.rb +18 -0
  30. data/lib/proscenium/middleware/url.rb +16 -0
  31. data/lib/proscenium/middleware.rb +76 -0
  32. data/lib/proscenium/monkey.rb +95 -0
  33. data/lib/proscenium/phlex/asset_inclusions.rb +17 -0
  34. data/lib/proscenium/phlex/css_modules.rb +79 -0
  35. data/lib/proscenium/phlex/react_component.rb +32 -0
  36. data/lib/proscenium/phlex.rb +42 -0
  37. data/lib/proscenium/railtie.rb +106 -0
  38. data/lib/proscenium/react_componentable.rb +95 -0
  39. data/lib/proscenium/resolver.rb +39 -0
  40. data/lib/proscenium/side_load.rb +155 -0
  41. data/lib/proscenium/source_path.rb +15 -0
  42. data/lib/proscenium/templates/rescues/build_error.html.erb +30 -0
  43. data/lib/proscenium/ui/breadcrumbs/component.module.css +14 -0
  44. data/lib/proscenium/ui/breadcrumbs/component.rb +79 -0
  45. data/lib/proscenium/ui/breadcrumbs/computed_element.rb +69 -0
  46. data/lib/proscenium/ui/breadcrumbs/control.rb +95 -0
  47. data/lib/proscenium/ui/breadcrumbs/mixins.css +83 -0
  48. data/lib/proscenium/ui/breadcrumbs.rb +72 -0
  49. data/lib/proscenium/ui/component.rb +11 -0
  50. data/lib/proscenium/ui/test.js +1 -0
  51. data/lib/proscenium/ui.rb +14 -0
  52. data/lib/proscenium/utils.rb +13 -0
  53. data/lib/proscenium/version.rb +5 -0
  54. data/lib/proscenium/view_component/css_modules.rb +11 -0
  55. data/lib/proscenium/view_component/react_component.rb +22 -0
  56. data/lib/proscenium/view_component/sideload.rb +4 -0
  57. data/lib/proscenium/view_component.rb +38 -0
  58. data/lib/proscenium.rb +70 -0
  59. metadata +228 -0
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Middleware
5
+ class Esbuild < Base
6
+ class CompileError < Base::CompileError
7
+ def initialize(args)
8
+ detail = args[:detail]
9
+ detail = ActiveSupport::HashWithIndifferentAccess.new(Oj.load(detail, mode: :strict))
10
+
11
+ args[:detail] = if detail[:location]
12
+ "#{detail[:text]} in #{detail[:location][:file]}:" +
13
+ detail[:location][:line].to_s
14
+ else
15
+ detail[:text]
16
+ end
17
+
18
+ super
19
+ end
20
+ end
21
+
22
+ def attempt
23
+ render_response Builder.build_to_string(path_to_build, root: Rails.root.to_s,
24
+ base_url: @request.base_url)
25
+ rescue Builder::CompileError => e
26
+ raise self.class::CompileError, { file: @request.fullpath, detail: e.message }, caller
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Middleware
5
+ class Runtime < Esbuild
6
+ private
7
+
8
+ def real_path
9
+ @real_path ||= Pathname.new(@request.path.sub(%r{^/@proscenium},
10
+ '/lib/proscenium/libs')).to_s
11
+ end
12
+
13
+ def root_for_readable
14
+ Proscenium::Railtie.root
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Middleware
5
+ # Handles requests for URL encoded URL's.
6
+ class Url < Esbuild
7
+ private
8
+
9
+ # @override [Esbuild] It's a URL, so always assume it is renderable (we won't actually know
10
+ # until it's downloaded).
11
+ def renderable?
12
+ true
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Middleware
5
+ extend ActiveSupport::Autoload
6
+
7
+ # Error when the build command fails.
8
+ class BuildError < StandardError; end
9
+
10
+ autoload :Base
11
+ autoload :Esbuild
12
+ autoload :Engines
13
+ autoload :Runtime
14
+ autoload :Url
15
+
16
+ def initialize(app)
17
+ @app = app
18
+
19
+ chunks_path = Rails.public_path.join('assets').to_s
20
+ headers = Rails.application.config.public_file_server.headers || {}
21
+ @chunk_handler = ::ActionDispatch::FileHandler.new(chunks_path, headers:)
22
+ end
23
+
24
+ def call(env)
25
+ request = Rack::Request.new(env)
26
+
27
+ return @app.call(env) if !request.get? && !request.head?
28
+ return @chunk_handler.attempt(request.env) if request.path.match?(%r{^/_asset_chunks/})
29
+
30
+ attempt(request) || @app.call(env)
31
+ end
32
+
33
+ private
34
+
35
+ def attempt(request)
36
+ return unless (type = find_type(request))
37
+
38
+ # file_handler.attempt(request.env) || type.attempt(request)
39
+
40
+ type.attempt request
41
+ end
42
+
43
+ def find_type(request)
44
+ return Url if request.path.match?(%r{^/https?%3A%2F%2F})
45
+ return Runtime if request.path.match?(%r{^/@proscenium/})
46
+ return Esbuild if Pathname.new(request.path).fnmatch?(app_path_glob, File::FNM_EXTGLOB)
47
+
48
+ pathname = Pathname.new(request.path)
49
+ Engines if pathname.fnmatch?(ui_path_glob, File::FNM_EXTGLOB) ||
50
+ pathname.fnmatch?(engines_path_glob, File::FNM_EXTGLOB)
51
+ end
52
+
53
+ def app_path_glob
54
+ "/{#{Proscenium::ALLOWED_DIRECTORIES}}/**.{#{file_extensions}}"
55
+ end
56
+
57
+ def engines_path_glob
58
+ names = Proscenium.config.engines.map(&:engine_name)
59
+ "/{#{names.join(',')}}/{#{Proscenium::ALLOWED_DIRECTORIES}}/**.{#{file_extensions}}"
60
+ end
61
+
62
+ def ui_path_glob
63
+ "/proscenium/ui/**.{#{file_extensions}}"
64
+ end
65
+
66
+ def file_extensions
67
+ @file_extensions ||= FILE_EXTENSIONS.join(',')
68
+ end
69
+
70
+ # TODO: handle precompiled assets
71
+ # def file_handler
72
+ # ::ActionDispatch::FileHandler.new Rails.public_path.join('assets').to_s,
73
+ # headers: { 'X-Proscenium-Middleware' => 'precompiled' }
74
+ # end
75
+ end
76
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
5
+ module Monkey
6
+ module TemplateRenderer
7
+ private
8
+
9
+ def render_template(view, template, layout_name, locals) # rubocop:disable Metrics/*
10
+ result = super
11
+ return result if !view.controller || !Proscenium.config.side_load
12
+
13
+ renderable = template.instance_variable_get(:@renderable)
14
+
15
+ to_sideload = if Object.const_defined?(:ViewComponent) &&
16
+ template.is_a?(ActionView::Template::Renderable) &&
17
+ renderable.class < ::ViewComponent::Base &&
18
+ renderable.class.format == :html
19
+ renderable
20
+ elsif template.respond_to?(:virtual_path) &&
21
+ template.respond_to?(:type) && template.type == :html
22
+ template
23
+ end
24
+ if to_sideload
25
+ options = view.controller.sideload_assets_options
26
+ layout = find_layout(layout_name, locals.keys, [formats.first])
27
+ sideload_template_assets layout, view.controller, options if layout
28
+ sideload_template_assets to_sideload, view.controller, options
29
+ end
30
+
31
+ result
32
+ end
33
+
34
+ def sideload_template_assets(tpl, controller, options)
35
+ options = {} if options.nil?
36
+ options = { js: options, css: options } unless options.is_a?(Hash)
37
+
38
+ if tpl.instance_variable_defined?(:@sideload_assets_options)
39
+ tpl_options = tpl.instance_variable_get(:@sideload_assets_options)
40
+ options = case tpl_options
41
+ when Hash then options.deep_merge(tpl_options)
42
+ else
43
+ { js: tpl_options, css: tpl_options }
44
+ end
45
+ end
46
+
47
+ %i[css js].each do |k|
48
+ options[k] = controller.instance_eval(&options[k]) if options[k].is_a?(Proc)
49
+ end
50
+
51
+ Importer.sideload "app/views/#{tpl.virtual_path}", **options
52
+ end
53
+ end
54
+
55
+ module PartialRenderer
56
+ private
57
+
58
+ def render_partial_template(view, locals, template, layout, block)
59
+ result = super
60
+
61
+ return result if !view.controller || !Proscenium.config.side_load
62
+
63
+ if template.respond_to?(:virtual_path) &&
64
+ template.respond_to?(:type) && template.type == :html
65
+ options = view.controller.sideload_assets_options
66
+ sideload_template_assets layout, options if layout
67
+ sideload_template_assets template, options
68
+ end
69
+
70
+ result
71
+ end
72
+
73
+ def sideload_template_assets(tpl, options)
74
+ options = {} if options.nil?
75
+ options = { js: options, css: options } unless options.is_a?(Hash)
76
+
77
+ if tpl.instance_variable_defined?(:@sideload_assets_options)
78
+ tpl_options = tpl.instance_variable_get(:@sideload_assets_options)
79
+ options = if tpl_options.is_a?(Hash)
80
+ options.deep_merge tpl_options
81
+ else
82
+ { js: tpl_options, css: tpl_options }
83
+ end
84
+ end
85
+
86
+ %i[css js].each do |k|
87
+ options[k] = controller.instance_eval(&options[k]) if options[k].is_a?(Proc)
88
+ end
89
+
90
+ Importer.sideload "app/views/#{tpl.virtual_path}", **options
91
+ end
92
+ end
93
+ end
94
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
95
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium::Phlex::AssetInclusions
4
+ def include_stylesheets
5
+ comment { '[PROSCENIUM_STYLESHEETS]' }
6
+ end
7
+
8
+ def include_javascripts
9
+ comment { '[PROSCENIUM_LAZY_SCRIPTS]' }
10
+ comment { '[PROSCENIUM_JAVASCRIPTS]' }
11
+ end
12
+
13
+ def include_assets
14
+ include_stylesheets
15
+ include_javascripts
16
+ end
17
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ module Phlex::CssModules
5
+ include Proscenium::CssModule
6
+
7
+ def self.included(base)
8
+ base.extend CssModule::Path
9
+ base.extend ClassMethods
10
+ end
11
+
12
+ module ClassMethods
13
+ # Set of CSS module paths that have been resolved after being transformed from 'class' HTML
14
+ # attributes. See #process_attributes. This is here because Phlex caches attributes. Which
15
+ # means while the CSS class names will be transformed, any resolved paths will be lost in
16
+ # subsequent requests.
17
+ attr_accessor :resolved_css_module_paths
18
+ end
19
+
20
+ def before_template
21
+ self.class.resolved_css_module_paths ||= Concurrent::Set.new
22
+ super
23
+ end
24
+
25
+ def after_template
26
+ self.class.resolved_css_module_paths.each do |path|
27
+ Proscenium::Importer.import path
28
+ end
29
+
30
+ super
31
+ end
32
+
33
+ # Resolve and side load any CSS modules in the "class" attributes, where a CSS module is a class
34
+ # name beginning with a `@`. The class name is resolved to a CSS module name based on the file
35
+ # system path of the Phlex class, and any CSS file is side loaded.
36
+ #
37
+ # For example, the following will side load the CSS module file at
38
+ # app/components/user/component.module.css, and add the CSS Module name `user_name` to the
39
+ # <div>.
40
+ #
41
+ # # app/components/user/component.rb
42
+ # class User::Component < Proscenium::Phlex
43
+ # def view_template
44
+ # div class: :@user_name do
45
+ # 'Joel Moss'
46
+ # end
47
+ # end
48
+ # end
49
+ #
50
+ # Additionally, any class name containing a `/` is resolved as a CSS module path. Allowing you
51
+ # to use the same syntax as a CSS module, but without the need to manually import the CSS file.
52
+ #
53
+ # For example, the following will side load the CSS module file at /lib/users.module.css, and
54
+ # add the CSS Module name `name` to the <div>.
55
+ #
56
+ # class User::Component < Proscenium::Phlex
57
+ # def view_template
58
+ # div class: '/lib/users@name' do
59
+ # 'Joel Moss'
60
+ # end
61
+ # end
62
+ # end
63
+ #
64
+ # @raise [Proscenium::CssModule::Resolver::NotFound] If a CSS module file is not found for the
65
+ # Phlex class file path.
66
+ def process_attributes(**attributes)
67
+ if attributes.key?(:class) && (attributes[:class] = tokens(attributes[:class])).include?('@')
68
+ names = attributes[:class].is_a?(Array) ? attributes[:class] : attributes[:class].split
69
+
70
+ attributes[:class] = cssm.class_names(*names).map do |name, path|
71
+ self.class.resolved_css_module_paths << path if path
72
+ name
73
+ end
74
+ end
75
+
76
+ attributes
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
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 view_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 view_template(**attributes, &)
29
+ send(root_tag, **{ data: data_attributes }.deep_merge(attributes), &)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'phlex-rails'
4
+
5
+ module Proscenium
6
+ class Phlex < ::Phlex::HTML
7
+ extend ActiveSupport::Autoload
8
+
9
+ autoload :CssModules
10
+ autoload :ReactComponent
11
+ autoload :AssetInclusions
12
+
13
+ include Proscenium::SourcePath
14
+ include CssModules
15
+ include AssetInclusions
16
+
17
+ module Sideload
18
+ def before_template
19
+ Proscenium::SideLoad.sideload_inheritance_chain self,
20
+ helpers.controller.sideload_assets_options
21
+
22
+ super
23
+ end
24
+ end
25
+
26
+ class_attribute :sideload_assets_options
27
+
28
+ class << self
29
+ attr_accessor :abstract_class
30
+
31
+ def inherited(child)
32
+ child.prepend Sideload
33
+
34
+ super
35
+ end
36
+
37
+ def sideload_assets(value)
38
+ self.sideload_assets_options = value
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+ require 'proscenium/log_subscriber'
5
+
6
+ ENV['RAILS_ENV'] = Rails.env
7
+
8
+ module Proscenium
9
+ class Railtie < ::Rails::Engine
10
+ isolate_namespace Proscenium
11
+
12
+ config.proscenium = ActiveSupport::OrderedOptions.new
13
+ config.proscenium.debug = false
14
+ config.proscenium.side_load = true
15
+ config.proscenium.code_splitting = true
16
+
17
+ # Cache asset paths when building to path. Enabled by default in production.
18
+ # @see Proscenium::Builder#build_to_path
19
+ config.proscenium.cache = ActiveSupport::Cache::MemoryStore.new if Rails.env.production?
20
+
21
+ # TODO: implement!
22
+ config.proscenium.cache_query_string = Rails.env.production? && ENV.fetch('REVISION', nil)
23
+ config.proscenium.cache_max_age = 2_592_000 # 30 days
24
+
25
+ # List of environment variable names that should be passed to the builder, which will then be
26
+ # passed to esbuild's `Define` option. Being explicit about which environment variables are
27
+ # defined means a faster build, as esbuild will have less to do.
28
+ config.proscenium.env_vars = Set.new
29
+
30
+ # Rails engines to expose and allow Proscenium to serve their assets.
31
+ #
32
+ # A Rails engine that has assets, can add Proscenium as a gem dependency, and then add itself
33
+ # to this list. Proscenium will then serve the engine's assets at the URL path beginning with
34
+ # the engine name.
35
+ #
36
+ # Example:
37
+ # class Gem1::Engine < ::Rails::Engine
38
+ # config.proscenium.engines << self
39
+ # end
40
+ config.proscenium.engines = Set.new
41
+
42
+ config.action_dispatch.rescue_templates = {
43
+ 'Proscenium::Builder::BuildError' => 'build_error'
44
+ }
45
+
46
+ config.after_initialize do |_app|
47
+ ActiveSupport.on_load(:action_view) do
48
+ include Proscenium::Helper
49
+ end
50
+ end
51
+
52
+ initializer 'proscenium.ui' do
53
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
54
+ inflect.acronym 'UI'
55
+ end
56
+ end
57
+
58
+ initializer 'proscenium.debugging' do
59
+ if Rails.gem_version >= Gem::Version.new('7.1.0')
60
+ tpl_path = root.join('lib', 'proscenium', 'templates').to_s
61
+ ActionDispatch::DebugView::RESCUES_TEMPLATE_PATHS << tpl_path
62
+ end
63
+ end
64
+
65
+ initializer 'proscenium.middleware' do |app|
66
+ app.middleware.insert_after ActionDispatch::Static, Middleware
67
+ app.middleware.insert_after ActionDispatch::Static, Rack::ETag, 'no-cache'
68
+ app.middleware.insert_after ActionDispatch::Static, Rack::ConditionalGet
69
+ end
70
+
71
+ initializer 'proscenium.sideloading' do
72
+ ActiveSupport.on_load(:action_controller) do
73
+ ActionController::Base.include EnsureLoaded
74
+ ActionController::Base.include SideLoad::Controller
75
+ end
76
+ end
77
+
78
+ initializer 'proscenium.monkey_patches' do
79
+ ActiveSupport.on_load(:action_view) do
80
+ ActionView::TemplateRenderer.prepend Monkey::TemplateRenderer
81
+ ActionView::PartialRenderer.prepend Monkey::PartialRenderer
82
+ end
83
+ end
84
+
85
+ initializer 'proscenium.public_path' do |app|
86
+ if app.config.public_file_server.enabled
87
+ headers = app.config.public_file_server.headers || {}
88
+ index = app.config.public_file_server.index_name || 'index'
89
+
90
+ app.middleware.insert_after(ActionDispatch::Static, ActionDispatch::Static,
91
+ root.join('public').to_s, index:, headers:)
92
+ end
93
+ end
94
+ end
95
+ end
96
+
97
+ if Rails.gem_version < Gem::Version.new('7.1.0')
98
+ class ActionDispatch::DebugView
99
+ def initialize(assigns)
100
+ tpl_path = Proscenium::Railtie.root.join('lib', 'proscenium', 'templates').to_s
101
+ paths = [RESCUES_TEMPLATE_PATH, tpl_path]
102
+ lookup_context = ActionView::LookupContext.new(paths)
103
+ super(lookup_context, assigns, nil)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,95 @@
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
+
42
+ # @return [String] the URL path to the component manager.
43
+ class_attribute :manager, default: '/@proscenium/react-manager/index.jsx'
44
+ end
45
+
46
+ class_methods do
47
+ def sideload(options)
48
+ Importer.import manager, **options, js: { type: 'module' }
49
+ Importer.sideload source_path, lazy: true, **options
50
+ end
51
+ end
52
+
53
+ # @param props: [Hash]
54
+ def initialize(lazy: self.class.lazy, loader: self.class.loader, props: {})
55
+ self.lazy = lazy
56
+ self.loader = loader
57
+ @props = props
58
+ end
59
+
60
+ # The absolute URL path to the javascript component.
61
+ def virtual_path
62
+ @virtual_path ||= Resolver.resolve self.class.source_path.sub_ext('.jsx').to_s
63
+ end
64
+
65
+ def props
66
+ @props ||= {}
67
+ end
68
+
69
+ private
70
+
71
+ def data_attributes
72
+ {
73
+ proscenium_component_path: Pathname.new(virtual_path).to_s,
74
+ proscenium_component_props: prepared_props,
75
+ proscenium_component_lazy: lazy
76
+ }.tap do |x|
77
+ x[:proscenium_component_forward_children] = true if forward_children?
78
+ end
79
+ end
80
+
81
+ def prepared_props
82
+ props.deep_transform_keys do |term|
83
+ # This ensures that the first letter after a slash is not capitalized.
84
+ string = term.to_s.split('/').map { |str| str.camelize :lower }.join('/')
85
+
86
+ # Reverses the effect of ActiveSupport::Inflector.camelize converting slashes into `::`.
87
+ string.gsub '::', '/'
88
+ end.to_json
89
+ end
90
+
91
+ def loader_component
92
+ render Loader::Component.new(loader, @html_class, data_attributes, tag: @html_tag)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,39 @@
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
+ #
15
+ # rubocop:disable Metrics/*
16
+ def self.resolve(path)
17
+ self.resolved ||= {}
18
+
19
+ self.resolved[path] ||= begin
20
+ if path.start_with?('./', '../')
21
+ raise ArgumentError, 'path must be an absolute file system or URL path'
22
+ end
23
+
24
+ if path.start_with?('@proscenium/')
25
+ "/#{path}"
26
+ elsif path.start_with?(Proscenium.ui_path.to_s)
27
+ path.delete_prefix Proscenium.root.join('lib').to_s
28
+ elsif (engine = Proscenium.config.engines.find { |e| path.start_with? "#{e.root}/" })
29
+ path.sub(/^#{engine.root}/, "/#{engine.engine_name}")
30
+ elsif path.start_with?("#{Rails.root}/")
31
+ path.delete_prefix Rails.root.to_s
32
+ else
33
+ Builder.resolve path
34
+ end
35
+ end
36
+ end
37
+ # rubocop:enable Metrics/*
38
+ end
39
+ end