proscenium 0.16.0

Sign up to get free protection for your applications and to get access to all the features.
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