proscenium 0.10.0-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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +175 -39
  3. data/lib/proscenium/builder.rb +14 -10
  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/helper.rb +62 -0
  10. data/lib/proscenium/importer.rb +110 -0
  11. data/lib/proscenium/libs/react-manager/index.jsx +88 -0
  12. data/lib/proscenium/libs/react-manager/react.js +2 -0
  13. data/lib/proscenium/middleware/esbuild.rb +2 -4
  14. data/lib/proscenium/middleware.rb +2 -1
  15. data/lib/proscenium/{side_load/monkey.rb → monkey.rb} +11 -15
  16. data/lib/proscenium/phlex/{resolve_css_modules.rb → css_modules.rb} +6 -20
  17. data/lib/proscenium/phlex/page.rb +2 -2
  18. data/lib/proscenium/phlex/react_component.rb +26 -27
  19. data/lib/proscenium/phlex.rb +10 -29
  20. data/lib/proscenium/railtie.rb +14 -26
  21. data/lib/proscenium/react_componentable.rb +94 -0
  22. data/lib/proscenium/resolver.rb +37 -0
  23. data/lib/proscenium/side_load.rb +13 -73
  24. data/lib/proscenium/source_path.rb +15 -0
  25. data/lib/proscenium/utils.rb +13 -0
  26. data/lib/proscenium/version.rb +1 -1
  27. data/lib/proscenium/view_component/css_modules.rb +11 -0
  28. data/lib/proscenium/view_component/react_component.rb +15 -15
  29. data/lib/proscenium/view_component/sideload.rb +4 -0
  30. data/lib/proscenium/view_component.rb +8 -38
  31. data/lib/proscenium.rb +23 -68
  32. metadata +19 -29
  33. data/lib/proscenium/componentable.rb +0 -63
  34. data/lib/proscenium/css_module/class_names_resolver.rb +0 -66
  35. data/lib/proscenium/css_module/resolver.rb +0 -76
  36. data/lib/proscenium/current.rb +0 -9
  37. data/lib/proscenium/phlex/component_concerns.rb +0 -9
  38. data/lib/proscenium/side_load/ensure_loaded.rb +0 -25
  39. data/lib/proscenium/side_load/helper.rb +0 -41
  40. data/lib/proscenium/view_component/tag_builder.rb +0 -23
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/current_attributes'
4
+
5
+ module Proscenium
6
+ class Importer < ActiveSupport::CurrentAttributes
7
+ JS_EXTENSIONS = %w[.tsx .ts .jsx .js].freeze
8
+ CSS_EXTENSIONS = %w[.module.css .css].freeze
9
+
10
+ # Holds the JS and CSS files to include in the current request.
11
+ #
12
+ # Example:
13
+ # {
14
+ # '/path/to/input/file.js': {
15
+ # output: '/path/to/compiled/file.js',
16
+ # **options
17
+ # }
18
+ # }
19
+ attribute :imported
20
+
21
+ class << self
22
+ # Import the given `filepath`. This is idempotent - it will never include duplicates.
23
+ #
24
+ # @param filepath [String] Absolute path (relative to Rails root) of the file to import.
25
+ # Should be the actual asset file, eg. app.css, some/component.js.
26
+ # @param resolve [String] description of the file to resolve and import.
27
+ # @return [String] the digest of the imported file path if a css module (*.module.css).
28
+ def import(filepath = nil, resolve: nil, **options)
29
+ self.imported ||= {}
30
+
31
+ filepath = Resolver.resolve(resolve) if !filepath && resolve
32
+ css_module = filepath.end_with?('.module.css')
33
+
34
+ unless self.imported.key?(filepath)
35
+ # ActiveSupport::Notifications.instrument('sideload.proscenium', identifier: value)
36
+
37
+ self.imported[filepath] = { **options }
38
+ self.imported[filepath][:digest] = Utils.digest(filepath) if css_module
39
+ end
40
+
41
+ css_module ? self.imported[filepath][:digest] : nil
42
+ end
43
+
44
+ # Sideloads JS and CSS assets for the given Ruby filepath.
45
+ #
46
+ # Any files with the same base name and matching a supported extension will be sideloaded.
47
+ # Only one JS and one CSS file will be sideloaded, with the first match used in the following
48
+ # order:
49
+ # - JS extensions: .tsx, .ts, .jsx, and .js.
50
+ # - CSS extensions: .css.module, and .css.
51
+ #
52
+ # Example:
53
+ # - `app/views/layouts/application.rb`
54
+ # - `app/views/layouts/application.css`
55
+ # - `app/views/layouts/application.js`
56
+ # - `app/views/layouts/application.tsx`
57
+ #
58
+ # A request to sideload `app/views/layouts/application.rb` will result in `application.css`
59
+ # and `application.tsx` being sideloaded. `application.js` will not be sideloaded because the
60
+ # `.tsx` extension is matched first.
61
+ #
62
+ # @param filepath [Pathname] Absolute file system path of the Ruby file to sideload.
63
+ def sideload(filepath, **options)
64
+ return unless Proscenium.config.side_load
65
+
66
+ filepath = Rails.root.join(filepath) unless filepath.is_a?(Pathname)
67
+ filepath = filepath.sub_ext('')
68
+
69
+ import_if_exists = lambda do |x|
70
+ if (fp = filepath.sub_ext(x)).exist?
71
+ import(Resolver.resolve(fp.to_s), sideloaded: true, **options)
72
+ end
73
+ end
74
+
75
+ JS_EXTENSIONS.find(&import_if_exists)
76
+ CSS_EXTENSIONS.find(&import_if_exists)
77
+ end
78
+
79
+ def each_stylesheet(delete: false)
80
+ return if imported.blank?
81
+
82
+ blk = proc { |key, options| key.end_with?(*CSS_EXTENSIONS) && yield(key, options) }
83
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
84
+ end
85
+
86
+ def each_javascript(delete: false)
87
+ return if imported.blank?
88
+
89
+ blk = proc { |key, options| key.end_with?(*JS_EXTENSIONS) && yield(key, options) }
90
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
91
+ end
92
+
93
+ def css_imported?
94
+ imported&.keys&.any? { |x| x.end_with?(*CSS_EXTENSIONS) }
95
+ end
96
+
97
+ def js_imported?
98
+ imported&.keys&.any? { |x| x.end_with?(*JS_EXTENSIONS) }
99
+ end
100
+
101
+ def multiple_js_imported?
102
+ imported&.keys&.many? { |x| x.end_with?(*JS_EXTENSIONS) }
103
+ end
104
+
105
+ def imported?(filepath = nil)
106
+ filepath ? imported&.key?(filepath) : !imported.blank?
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,88 @@
1
+ const elements = document.querySelectorAll("[data-proscenium-component-path]");
2
+
3
+ // Initialize only if there are components.
4
+ elements.length > 0 && init();
5
+
6
+ function init() {
7
+ /**
8
+ * Mounts component located at `path`, into the DOM `element`.
9
+ *
10
+ * The element at which the component is mounted must have the following data attributes:
11
+ *
12
+ * - `data-proscenium-component-path`: The URL path to the component's source file.
13
+ * - `data-proscenium-component-props`: JSON object of props to pass to the component.
14
+ * - `data-proscenium-component-lazy`: If present, will lazily load the component when in view
15
+ * using IntersectionObserver.
16
+ * - `data-proscenium-component-forward-children`: If the element should forward its `innerHTML`
17
+ * as the component's children prop.
18
+ */
19
+ function mount(element, path, { children, ...props }) {
20
+ // For testing and simulation of slow connections.
21
+ // const sim = new Promise((resolve) => setTimeout(resolve, 5000));
22
+
23
+ if (!(path in window.prosceniumLazyScripts)) {
24
+ throw `[proscenium/react/manager] Cannot load component ${path} (not found in prosceniumLazyScripts)`;
25
+ }
26
+
27
+ const react = import("@proscenium/react-manager/react");
28
+ const Component = import(window.prosceniumLazyScripts[path].outpath);
29
+
30
+ const forwardChildren =
31
+ "prosceniumComponentForwardChildren" in element.dataset &&
32
+ element.innerHTML !== "";
33
+
34
+ Promise.all([react, Component]).then(([r, c]) => {
35
+ if (proscenium.env.RAILS_ENV === "development") {
36
+ console.groupCollapsed(
37
+ `[proscenium/react/manager] 🔥 %o mounted!`,
38
+ path
39
+ );
40
+ console.log("props: %o", props);
41
+ console.groupEnd();
42
+ }
43
+
44
+ let component;
45
+ if (forwardChildren) {
46
+ component = r.createElement(c.default, props, element.innerHTML);
47
+ } else if (children) {
48
+ component = r.createElement(c.default, props, children);
49
+ } else {
50
+ component = r.createElement(c.default, props);
51
+ }
52
+
53
+ r.createRoot(element).render(component);
54
+ });
55
+ }
56
+
57
+ Array.from(elements, (element) => {
58
+ const path = element.dataset.prosceniumComponentPath;
59
+ const isLazy = "prosceniumComponentLazy" in element.dataset;
60
+ const props = JSON.parse(element.dataset.prosceniumComponentProps);
61
+
62
+ if (proscenium.env.RAILS_ENV === "development") {
63
+ console.groupCollapsed(
64
+ `[proscenium/react/manager] ${isLazy ? "💤" : "⚡️"} %o`,
65
+ path
66
+ );
67
+ console.log("element: %o", element);
68
+ console.log("props: %o", props);
69
+ console.groupEnd();
70
+ }
71
+
72
+ if (isLazy) {
73
+ const observer = new IntersectionObserver((entries) => {
74
+ entries.forEach((entry) => {
75
+ if (entry.isIntersecting) {
76
+ observer.unobserve(element);
77
+
78
+ mount(element, path, props);
79
+ }
80
+ });
81
+ });
82
+
83
+ observer.observe(element);
84
+ } else {
85
+ mount(element, path, props);
86
+ }
87
+ });
88
+ }
@@ -0,0 +1,2 @@
1
+ export { createElement } from "react";
2
+ export { createRoot } from "react-dom/client";
@@ -20,10 +20,8 @@ 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::Builder.build(path_to_build, root: root,
25
- base_url: @request.base_url)
26
- end
23
+ render_response Proscenium::Builder.build(path_to_build, root: root,
24
+ base_url: @request.base_url)
27
25
  rescue Proscenium::Builder::CompileError => e
28
26
  raise self.class::CompileError, { file: @request.fullpath, detail: e.message }, caller
29
27
  end
@@ -40,7 +40,8 @@ module Proscenium
40
40
 
41
41
  def find_type(request)
42
42
  return Url if request.path.match?(%r{^/https?%3A%2F%2F})
43
- 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)
44
45
  end
45
46
 
46
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,18 +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
- Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
23
+ Importer.sideload "app/views/#{layout.virtual_path}" if layout
22
24
 
23
25
  # Try side loading the variant template
24
26
  if template.respond_to?(:variant) && template.variant
25
- Proscenium::SideLoad.append "app/views/#{template.virtual_path}+#{template.variant}"
27
+ Importer.sideload "app/views/#{template.virtual_path}+#{template.variant}"
26
28
  end
27
29
 
28
- Proscenium::SideLoad.append "app/views/#{template.virtual_path}"
30
+ Importer.sideload "app/views/#{template.virtual_path}"
29
31
  end
30
32
 
31
33
  super
@@ -36,20 +38,14 @@ class Proscenium::SideLoad
36
38
  private
37
39
 
38
40
  def render_partial_template(view, locals, template, layout, block)
39
- if template.respond_to?(:virtual_path) &&
41
+ if Proscenium.config.side_load && template.respond_to?(:virtual_path) &&
40
42
  template.respond_to?(:type) && template.type == :html
41
- Proscenium::SideLoad.append "app/views/#{layout.virtual_path}" if layout
42
- Proscenium::SideLoad.append "app/views/#{template.virtual_path}"
43
+ Importer.sideload "app/views/#{layout.virtual_path}" if layout
44
+ Importer.sideload "app/views/#{template.virtual_path}"
43
45
  end
44
46
 
45
47
  super
46
48
  end
47
-
48
- def build_rendered_template(content, template)
49
- path = Rails.root.join('app', 'views', template.virtual_path)
50
- cssm = Proscenium::CssModule::Resolver.new(path)
51
- super cssm.compile_class_names(content), template
52
- end
53
49
  end
54
50
  end
55
51
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
@@ -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 type: :module, defer: true
59
+ include_javascripts type: :module, defer: true
60
60
  end
61
61
  end
62
62
  end
@@ -1,33 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- #
4
- # Renders a <div> for use with React components, with data attributes specifying the component path
5
- # and props.
6
- #
7
- # If a block is given, it will be yielded within the div, allowing for a custom "loading" UI. If no
8
- # block is given, then a "loading..." text will be rendered. It is intended that the component is
9
- # 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
- #
14
- class Proscenium::Phlex::ReactComponent < Proscenium::Phlex
15
- self.abstract_class = true
16
-
17
- include Proscenium::Componentable
18
- include Proscenium::Phlex::ComponentConcerns::CssModules
19
-
20
- # Override this to provide your own loading UI.
3
+ module Proscenium
4
+ # Renders a <div> for use with React components, with data attributes specifying the component
5
+ # path and props.
21
6
  #
22
- # @example
23
- # def template(**attributes, &block)
24
- # super do
25
- # 'Look at me! I am loading now...'
26
- # end
27
- # end
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.
28
11
  #
29
- # @yield the given block to a `div` within the top level component div.
30
- def template(**attributes, &block)
31
- send root_tag, **{ data: data_attributes }.deep_merge(attributes), &block
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
30
+ end
32
31
  end
33
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,14 +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
- # Environment variables that should always be passed to the builder.
15
- DEFAULT_ENV_VARS = Set['RAILS_ENV', 'NODE_ENV'].freeze
16
-
17
9
  class << self
18
10
  def config
19
11
  @config ||= Railtie.config.proscenium
@@ -26,10 +18,12 @@ module Proscenium
26
18
  config.proscenium = ActiveSupport::OrderedOptions.new
27
19
  config.proscenium.debug = false
28
20
  config.proscenium.side_load = true
29
- config.proscenium.code_splitting = false
21
+ config.proscenium.code_splitting = true
22
+ config.proscenium.include_paths = Set.new(APPLICATION_INCLUDE_PATHS)
23
+
24
+ # TODO: implement!
30
25
  config.proscenium.cache_query_string = Rails.env.production? && ENV.fetch('REVISION', nil)
31
26
  config.proscenium.cache_max_age = 2_592_000 # 30 days
32
- config.proscenium.include_paths = Set.new(APPLICATION_INCLUDE_PATHS)
33
27
 
34
28
  # List of environment variable names that should be passed to the builder, which will then be
35
29
  # passed to esbuild's `Define` option. Being explicit about which environment variables are
@@ -60,31 +54,25 @@ module Proscenium
60
54
  end
61
55
 
62
56
  initializer 'proscenium.middleware' do |app|
63
- app.middleware.insert_after ActionDispatch::Static, Proscenium::Middleware
57
+ app.middleware.insert_after ActionDispatch::Static, Middleware
64
58
  app.middleware.insert_after ActionDispatch::Static, Rack::ETag, 'no-cache'
65
59
  app.middleware.insert_after ActionDispatch::Static, Rack::ConditionalGet
66
60
  end
67
61
 
68
- initializer 'proscenium.side_loading' do |app|
69
- if app.config.proscenium.side_load
70
- Proscenium::Current.loaded ||= SideLoad::EXTENSIONS.to_h { |e| [e, Set.new] }
71
-
72
- ActiveSupport.on_load(:action_view) do
73
- ActionView::Base.include Proscenium::SideLoad::Helper
74
-
75
- ActionView::TemplateRenderer.prepend SideLoad::Monkey::TemplateRenderer
76
- ActionView::PartialRenderer.prepend SideLoad::Monkey::PartialRenderer
77
- end
78
-
79
- ActiveSupport.on_load(:action_controller) do
80
- ActionController::Base.include Proscenium::SideLoad::EnsureLoaded
81
- 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
82
66
  end
83
67
  end
84
68
 
85
69
  initializer 'proscenium.helper' do
86
70
  ActiveSupport.on_load(:action_view) do
87
- 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
88
76
  end
89
77
  end
90
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