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,134 @@
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 URL 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, **)
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] = { ** }
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 if !Proscenium.config.side_load || (options[:js] == false && options[:css] == false)
65
+
66
+ sideload_js(filepath, **options) unless options[:js] == false
67
+ sideload_css(filepath, **options) unless options[:css] == false
68
+ end
69
+
70
+ def sideload_js(filepath, **options)
71
+ return unless Proscenium.config.side_load
72
+
73
+ filepath = Rails.root.join(filepath) unless filepath.is_a?(Pathname)
74
+ filepath = filepath.sub_ext('')
75
+
76
+ JS_EXTENSIONS.find do |x|
77
+ if (fp = filepath.sub_ext(x)).exist?
78
+ import(Resolver.resolve(fp.to_s), sideloaded: true, **options)
79
+ end
80
+ end
81
+ end
82
+
83
+ def sideload_css(filepath, **options)
84
+ return unless Proscenium.config.side_load
85
+
86
+ filepath = Rails.root.join(filepath) unless filepath.is_a?(Pathname)
87
+ filepath = filepath.sub_ext('')
88
+
89
+ CSS_EXTENSIONS.find do |x|
90
+ if (fp = filepath.sub_ext(x)).exist?
91
+ import(Resolver.resolve(fp.to_s), sideloaded: true, **options)
92
+ end
93
+ end
94
+ end
95
+
96
+ def each_stylesheet(delete: false)
97
+ return if imported.blank?
98
+
99
+ blk = proc do |key, options|
100
+ if key.end_with?(*CSS_EXTENSIONS)
101
+ yield(key, options)
102
+ true
103
+ end
104
+ end
105
+
106
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
107
+ end
108
+
109
+ def each_javascript(delete: false)
110
+ return if imported.blank?
111
+
112
+ blk = proc do |key, options|
113
+ if key.end_with?(*JS_EXTENSIONS)
114
+ yield(key, options)
115
+ true
116
+ end
117
+ end
118
+ delete ? imported.delete_if(&blk) : imported.each(&blk)
119
+ end
120
+
121
+ def css_imported?
122
+ imported&.keys&.any? { |x| x.end_with?(*CSS_EXTENSIONS) }
123
+ end
124
+
125
+ def js_imported?
126
+ imported&.keys&.any? { |x| x.end_with?(*JS_EXTENSIONS) }
127
+ end
128
+
129
+ def imported?(filepath = nil)
130
+ filepath ? imported&.key?(filepath) : !imported.blank?
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Base class for custom elements, providing support for event delegation, and idempotent
3
+ * customElement registration.
4
+ *
5
+ * The `handleEvent` method is called any time an event defined in `delegatedEvents` is triggered.
6
+ * It's a central handler to handle events for this custom element.
7
+ *
8
+ * @example
9
+ * class MyComponent extends CustomElement {
10
+ * static componentName = 'my-component'
11
+ * static delegatedEvents = ['click']
12
+ *
13
+ * handleEvent(event) {
14
+ * console.log('Hello, world!')
15
+ * }
16
+ * }
17
+ * MyComponent.register()
18
+ */
19
+ export default class CustomElement extends HTMLElement {
20
+ /**
21
+ * Register the component as a custom element, inferring the component name from the kebab-cased
22
+ * class name. You can override the component name by setting a static `componentName` property.
23
+ *
24
+ * This method is idempotent.
25
+ */
26
+ static register() {
27
+ if (this.componentName === undefined) {
28
+ this.componentName = this.name
29
+ .replaceAll(/(.)([A-Z])/g, "$1-$2")
30
+ .toLowerCase();
31
+ }
32
+
33
+ if (!customElements.get(this.componentName)) {
34
+ customElements.define(this.componentName, this);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * A list of event types to be delegated for the lifetime of the custom element.
40
+ *
41
+ * @type {Array}
42
+ */
43
+ static delegatedEvents = [];
44
+
45
+ constructor() {
46
+ super();
47
+
48
+ if (typeof this.handleEvent !== "undefined") {
49
+ this.constructor.delegatedEvents?.forEach((event) => {
50
+ this.addEventListener(event, this);
51
+ });
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,121 @@
1
+ window.Proscenium = window.Proscenium || { lazyScripts: {} };
2
+ const pathAttribute = "data-proscenium-component-path";
3
+
4
+ // Find lazyscripts JSON already in the DOM.
5
+ const element = document.querySelector("#prosceniumLazyScripts");
6
+ if (element) {
7
+ window.Proscenium.lazyScripts = {
8
+ ...window.Proscenium.lazyScripts,
9
+ ...JSON.parse(element.text),
10
+ };
11
+ }
12
+
13
+ // Find components already in the DOM.
14
+ const elements = document.querySelectorAll(`[${pathAttribute}]`);
15
+ elements.length > 0 && init(elements);
16
+
17
+ new MutationObserver((mutationsList) => {
18
+ for (const { addedNodes } of mutationsList) {
19
+ for (const ele of addedNodes) {
20
+ if (ele.tagName === "SCRIPT" && ele.id === "prosceniumLazyScripts") {
21
+ window.Proscenium.lazyScripts = {
22
+ ...window.Proscenium.lazyScripts,
23
+ ...JSON.parse(ele.text),
24
+ };
25
+ } else if (ele.matches(`[${pathAttribute}]`)) {
26
+ init([ele]);
27
+ }
28
+ }
29
+ }
30
+ }).observe(document, {
31
+ subtree: true,
32
+ childList: true,
33
+ });
34
+
35
+ function init(elements) {
36
+ Array.from(elements, (element) => {
37
+ const path = element.dataset.prosceniumComponentPath;
38
+ const isLazy = "prosceniumComponentLazy" in element.dataset;
39
+ const props = JSON.parse(element.dataset.prosceniumComponentProps);
40
+
41
+ if (proscenium.env.RAILS_ENV === "development") {
42
+ console.groupCollapsed(
43
+ `[proscenium/react/manager] ${isLazy ? "💤" : "⚡️"} %o`,
44
+ path
45
+ );
46
+ console.log("element: %o", element);
47
+ console.log("props: %o", props);
48
+ console.groupEnd();
49
+ }
50
+
51
+ if (isLazy) {
52
+ const observer = new IntersectionObserver((entries) => {
53
+ entries.forEach((entry) => {
54
+ if (entry.isIntersecting) {
55
+ observer.unobserve(element);
56
+
57
+ mount(element, path, props);
58
+ }
59
+ });
60
+ });
61
+
62
+ observer.observe(element);
63
+ } else {
64
+ mount(element, path, props);
65
+ }
66
+ });
67
+
68
+ /**
69
+ * Mounts component located at `path`, into the DOM `element`.
70
+ *
71
+ * The element at which the component is mounted must have the following data attributes:
72
+ *
73
+ * - `data-proscenium-component-path`: The URL path to the component's source file.
74
+ * - `data-proscenium-component-props`: JSON object of props to pass to the component.
75
+ * - `data-proscenium-component-lazy`: If present, will lazily load the component when in view
76
+ * using IntersectionObserver.
77
+ * - `data-proscenium-component-forward-children`: If the element should forward its `innerHTML`
78
+ * as the component's children prop.
79
+ */
80
+ function mount(element, path, { children, ...props }) {
81
+ // For testing and simulation of slow connections.
82
+ // const sim = new Promise((resolve) => setTimeout(resolve, 5000));
83
+
84
+ if (!window.Proscenium.lazyScripts[path]) {
85
+ throw `[proscenium/react/manager] Cannot load component ${path} (not found in Proscenium.lazyScripts)`;
86
+ }
87
+
88
+ const react = import("@proscenium/react-manager/react");
89
+ const Component = import(window.Proscenium.lazyScripts[path].outpath);
90
+
91
+ const forwardChildren =
92
+ "prosceniumComponentForwardChildren" in element.dataset &&
93
+ element.innerHTML !== "";
94
+
95
+ Promise.all([react, Component])
96
+ .then(([r, c]) => {
97
+ if (proscenium.env.RAILS_ENV === "development") {
98
+ console.groupCollapsed(
99
+ `[proscenium/react/manager] 🔥 %o mounted!`,
100
+ path
101
+ );
102
+ console.log("props: %o", props);
103
+ console.groupEnd();
104
+ }
105
+
106
+ let component;
107
+ if (forwardChildren) {
108
+ component = r.createElement(c.default, props, element.innerHTML);
109
+ } else if (children) {
110
+ component = r.createElement(c.default, props, children);
111
+ } else {
112
+ component = r.createElement(c.default, props);
113
+ }
114
+
115
+ r.createRoot(element).render(component);
116
+ })
117
+ .catch((error) => {
118
+ console.error("[proscenium/react/manager] %o - %o", path, error);
119
+ });
120
+ }
121
+ }
@@ -0,0 +1,2 @@
1
+ export { createElement } from "react";
2
+ export { createRoot } from "react-dom/client";
@@ -0,0 +1,65 @@
1
+ export function lazyLoadControllersFrom(under, app, element = document) {
2
+ const { controllerAttribute } = app.schema;
3
+
4
+ lazyLoadExistingControllers(element);
5
+
6
+ // Lazy load new controllers.
7
+ new MutationObserver((mutationsList) => {
8
+ for (const { attributeName, target, type } of mutationsList) {
9
+ switch (type) {
10
+ case "attributes": {
11
+ if (
12
+ attributeName == controllerAttribute &&
13
+ target.getAttribute(controllerAttribute)
14
+ ) {
15
+ extractControllerNamesFrom(target).forEach((controllerName) =>
16
+ loadController(controllerName)
17
+ );
18
+ }
19
+ }
20
+
21
+ case "childList": {
22
+ lazyLoadExistingControllers(target);
23
+ }
24
+ }
25
+ }
26
+ }).observe(element, {
27
+ attributeFilter: [controllerAttribute],
28
+ subtree: true,
29
+ childList: true,
30
+ });
31
+
32
+ function lazyLoadExistingControllers(element) {
33
+ Array.from(element.querySelectorAll(`[${controllerAttribute}]`))
34
+ .map(extractControllerNamesFrom)
35
+ .flat()
36
+ .forEach(loadController);
37
+ }
38
+
39
+ function extractControllerNamesFrom(element) {
40
+ return element
41
+ .getAttribute(controllerAttribute)
42
+ .split(/\s+/)
43
+ .filter((content) => content.length);
44
+ }
45
+
46
+ function loadController(name) {
47
+ if (canRegisterController(name)) {
48
+ const fileToImport = `${under}/${name
49
+ .replace(/--/g, "/")
50
+ .replace(/-/g, "_")}_controller.js`;
51
+
52
+ import(fileToImport)
53
+ .then((module) => {
54
+ canRegisterController(name) && app.register(name, module.default);
55
+ })
56
+ .catch((error) =>
57
+ console.error(`Failed to autoload controller: ${name}`, error)
58
+ );
59
+ }
60
+ }
61
+
62
+ function canRegisterController(name) {
63
+ return !app.router.modulesByIdentifier.has(name);
64
+ }
65
+ }
@@ -0,0 +1 @@
1
+ console.log("/@proscenium/test.js");
@@ -0,0 +1,15 @@
1
+ import DataConfirm from "./data_confirm";
2
+ import DataDisableWith from "./data_disable_with";
3
+
4
+ export default class UJS {
5
+ constructor() {
6
+ this.dc = new DataConfirm();
7
+ this.ddw = new DataDisableWith();
8
+
9
+ document.addEventListener("submit", this, { capture: true });
10
+ }
11
+
12
+ handleEvent(event) {
13
+ this.dc.onSubmit(event) && this.ddw.onSubmit(event);
14
+ }
15
+ }
@@ -0,0 +1,23 @@
1
+ export default class DataConfirm {
2
+ onSubmit = (event) => {
3
+ if (
4
+ !event.target.matches("[data-turbo=true]") &&
5
+ event.submitter &&
6
+ "confirm" in event.submitter.dataset
7
+ ) {
8
+ const v = event.submitter.dataset.confirm;
9
+
10
+ if (
11
+ v !== "false" &&
12
+ !confirm(v === "true" || v === "" ? "Are you sure?" : v)
13
+ ) {
14
+ event.preventDefault();
15
+ event.stopPropagation();
16
+ event.stopImmediatePropagation();
17
+ return false;
18
+ }
19
+ }
20
+
21
+ return true;
22
+ };
23
+ }
@@ -0,0 +1,68 @@
1
+ export default class DataDisableWith {
2
+ onSubmit = event => {
3
+ const target = event.target
4
+ const formId = target.id
5
+
6
+ if (target.matches('[data-turbo=true]')) return
7
+
8
+ const submitElements = Array.from(
9
+ target.querySelectorAll(
10
+ ['input[type=submit][data-disable-with]', 'button[type=submit][data-disable-with]'].join(
11
+ ', '
12
+ )
13
+ )
14
+ )
15
+
16
+ submitElements.push(
17
+ ...Array.from(
18
+ document.querySelectorAll(
19
+ [
20
+ `input[type=submit][data-disable-with][form='${formId}']`,
21
+ `button[type=submit][data-disable-with][form='${formId}']`
22
+ ].join(', ')
23
+ )
24
+ )
25
+ )
26
+
27
+ for (const ele of submitElements) {
28
+ if (ele.hasAttribute('form') && ele.getAttribute('form') !== target.id) continue
29
+
30
+ this.#disableButton(ele)
31
+ }
32
+ }
33
+
34
+ #disableButton(ele) {
35
+ const defaultTextValue = 'Please wait...'
36
+ let textValue = ele.dataset.disableWith || defaultTextValue
37
+ if (textValue === 'false') return
38
+ if (textValue === 'true') {
39
+ textValue = defaultTextValue
40
+ }
41
+
42
+ ele.disabled = true
43
+
44
+ if (ele.matches('button')) {
45
+ ele.dataset.valueBeforeDisabled = ele.innerHTML
46
+ ele.innerHTML = textValue
47
+ } else {
48
+ ele.dataset.valueBeforeDisabled = ele.value
49
+ ele.value = textValue
50
+ }
51
+
52
+ if (ele.resetDisableWith === undefined) {
53
+ // This function can be called on the element to reset the disabled state. Useful for when
54
+ // form submission fails, and the button should be re-enabled.
55
+ ele.resetDisableWith = function () {
56
+ this.disabled = false
57
+
58
+ if (this.matches('button')) {
59
+ this.innerHTML = this.dataset.valueBeforeDisabled
60
+ } else {
61
+ this.value = this.dataset.valueBeforeDisabled
62
+ }
63
+
64
+ delete this.dataset.valueBeforeDisabled
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,9 @@
1
+ export default async () => {
2
+ window.Proscenium = window.Proscenium || {};
3
+
4
+ if (!window.Proscenium.UJS) {
5
+ const classPath = "/@proscenium/ujs/class.js";
6
+ const module = await import(classPath);
7
+ window.Proscenium.UJS = new module.default();
8
+ }
9
+ };
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/log_subscriber'
4
+
5
+ module Proscenium
6
+ class LogSubscriber < ActiveSupport::LogSubscriber
7
+ def sideload(event)
8
+ info do
9
+ " [Proscenium] Side loaded #{event.payload[:identifier]}"
10
+ end
11
+ end
12
+
13
+ def build_to_path(event)
14
+ path = event.payload[:identifier]
15
+ cached = event.payload[:cached] ? ' | Cached!' : ''
16
+ path = CGI.unescape(path) if path.start_with?(/https?%3A%2F%2F/)
17
+
18
+ info do
19
+ message = " #{color('[Proscenium]', nil, bold: true)} Building (to path) #{path}"
20
+ message << " (Duration: #{event.duration.round(1)}ms | " \
21
+ "Allocations: #{event.allocations}#{cached})"
22
+ end
23
+ end
24
+
25
+ def build_to_string(event)
26
+ path = event.payload[:identifier]
27
+ path = CGI.unescape(path) if path.start_with?(/https?%3A%2F%2F/)
28
+
29
+ info do
30
+ message = " #{color('[Proscenium]', nil, bold: true)} Building #{path}"
31
+ message << " (Duration: #{event.duration.round(1)}ms | Allocations: #{event.allocations})"
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ Proscenium::LogSubscriber.attach_to :proscenium
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'oj'
4
+
5
+ module Proscenium
6
+ class Middleware
7
+ class Base
8
+ include ActiveSupport::Benchmarkable
9
+
10
+ # Error when the result of the build returns an error. For example, when esbuild returns
11
+ # errors.
12
+ class CompileError < StandardError
13
+ attr_reader :detail, :file
14
+
15
+ def initialize(args)
16
+ @detail = args[:detail]
17
+ @file = args[:file]
18
+ super("Failed to build '#{args[:file]}' -- #{detail}")
19
+ end
20
+ end
21
+
22
+ def self.attempt(request)
23
+ new(request).renderable!&.attempt
24
+ end
25
+
26
+ def initialize(request)
27
+ @request = request
28
+ end
29
+
30
+ def renderable!
31
+ renderable? ? self : nil
32
+ end
33
+
34
+ private
35
+
36
+ def real_path
37
+ @real_path ||= @request.path
38
+ end
39
+
40
+ # @return [String] the path to the file without the leading slash which will be built.
41
+ def path_to_build
42
+ @path_to_build ||= @request.path[1..]
43
+ end
44
+
45
+ def sourcemap?
46
+ @request.path.ends_with?('.map')
47
+ end
48
+
49
+ def renderable?
50
+ file_readable?
51
+ end
52
+
53
+ def file_readable?
54
+ return false unless (path = clean_path(sourcemap? ? real_path[0...-4] : real_path))
55
+
56
+ file_stat = File.stat(root_for_readable.join(path.delete_prefix('/').b).to_s)
57
+ rescue SystemCallError
58
+ false
59
+ else
60
+ file_stat.file? && file_stat.readable?
61
+ end
62
+
63
+ def root_for_readable
64
+ Rails.root
65
+ end
66
+
67
+ def clean_path(file)
68
+ path = Rack::Utils.unescape_path file.chomp('/').delete_prefix('/')
69
+ Rack::Utils.clean_path_info path if Rack::Utils.valid_path? path
70
+ end
71
+
72
+ def content_type
73
+ case ::File.extname(path_to_build)
74
+ when '.js', '.mjs', '.ts', '.tsx', '.jsx' then 'application/javascript'
75
+ when '.css' then 'text/css'
76
+ when '.map' then 'application/json'
77
+ else
78
+ ::Rack::Mime.mime_type(::File.extname(path_to_build), nil) || 'application/javascript'
79
+ end
80
+ end
81
+
82
+ def render_response(content)
83
+ response = Rack::Response.new
84
+ response.write content
85
+ response.content_type = content_type
86
+ response['X-Proscenium-Middleware'] = name
87
+ response.set_header 'SourceMap', "#{@request.path_info}.map"
88
+
89
+ if Proscenium.config.cache_query_string && Proscenium.config.cache_max_age
90
+ response.cache! Proscenium.config.cache_max_age
91
+ end
92
+
93
+ yield response if block_given?
94
+
95
+ response.finish
96
+ end
97
+
98
+ def name
99
+ @name ||= self.class.name.split('::').last.downcase
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proscenium
4
+ class Middleware
5
+ # This middleware handles requests for assets in Rails engines. An engine that wants to expose
6
+ # its assets via Proscenium to the application must add itself to the list of engines in the
7
+ # Proscenium config options `Proscenium.config.engines`.
8
+ #
9
+ # For example, we have a gem that exposes a Rails engine.
10
+ #
11
+ # module Gem1
12
+ # class Engine < ::Rails::Engine
13
+ # config.proscenium.engines << self
14
+ # end
15
+ # end
16
+ #
17
+ # When this gem is installed in any Rails application, its assets will be available at the URL
18
+ # `/gem1/...`. For example, if the gem has a file `lib/styles.css`, it can be requested at
19
+ # `/gem1/lib/styles.css`.
20
+ #
21
+ class Engines < Esbuild
22
+ def real_path
23
+ @real_path ||= Pathname.new(@request.path.delete_prefix("/#{engine_name}")).to_s
24
+ end
25
+
26
+ def root_for_readable
27
+ ui? ? Proscenium.ui_path : engine.root
28
+ end
29
+
30
+ def engine
31
+ @engine ||= Proscenium.config.engines.find do |x|
32
+ @request.path.start_with?("/#{x.engine_name}")
33
+ end
34
+ end
35
+
36
+ def engine_name
37
+ ui? ? 'proscenium/ui' : engine.engine_name
38
+ end
39
+
40
+ def ui?
41
+ @request.path.start_with?('/proscenium/ui/')
42
+ end
43
+ end
44
+ end
45
+ end