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.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +84 -0
- data/LICENSE.txt +21 -0
- data/README.md +908 -0
- data/lib/proscenium/builder.rb +189 -0
- data/lib/proscenium/core_ext/object/css_module_ivars.rb +19 -0
- data/lib/proscenium/css_module/path.rb +31 -0
- data/lib/proscenium/css_module/rewriter.rb +44 -0
- data/lib/proscenium/css_module/transformer.rb +84 -0
- data/lib/proscenium/css_module.rb +57 -0
- data/lib/proscenium/ensure_loaded.rb +27 -0
- data/lib/proscenium/ext/proscenium +0 -0
- data/lib/proscenium/ext/proscenium.h +131 -0
- data/lib/proscenium/helper.rb +70 -0
- data/lib/proscenium/importer.rb +134 -0
- data/lib/proscenium/libs/custom_element.js +54 -0
- data/lib/proscenium/libs/react-manager/index.jsx +121 -0
- data/lib/proscenium/libs/react-manager/react.js +2 -0
- data/lib/proscenium/libs/stimulus-loading.js +65 -0
- data/lib/proscenium/libs/test.js +1 -0
- data/lib/proscenium/libs/ujs/class.js +15 -0
- data/lib/proscenium/libs/ujs/data_confirm.js +23 -0
- data/lib/proscenium/libs/ujs/data_disable_with.js +68 -0
- data/lib/proscenium/libs/ujs/index.js +9 -0
- data/lib/proscenium/log_subscriber.rb +37 -0
- data/lib/proscenium/middleware/base.rb +103 -0
- data/lib/proscenium/middleware/engines.rb +45 -0
- data/lib/proscenium/middleware/esbuild.rb +30 -0
- data/lib/proscenium/middleware/runtime.rb +18 -0
- data/lib/proscenium/middleware/url.rb +16 -0
- data/lib/proscenium/middleware.rb +76 -0
- data/lib/proscenium/monkey.rb +95 -0
- data/lib/proscenium/phlex/asset_inclusions.rb +17 -0
- data/lib/proscenium/phlex/css_modules.rb +79 -0
- data/lib/proscenium/phlex/react_component.rb +32 -0
- data/lib/proscenium/phlex.rb +42 -0
- data/lib/proscenium/railtie.rb +106 -0
- data/lib/proscenium/react_componentable.rb +95 -0
- data/lib/proscenium/resolver.rb +39 -0
- data/lib/proscenium/side_load.rb +155 -0
- data/lib/proscenium/source_path.rb +15 -0
- data/lib/proscenium/templates/rescues/build_error.html.erb +30 -0
- data/lib/proscenium/ui/breadcrumbs/component.module.css +14 -0
- data/lib/proscenium/ui/breadcrumbs/component.rb +79 -0
- data/lib/proscenium/ui/breadcrumbs/computed_element.rb +69 -0
- data/lib/proscenium/ui/breadcrumbs/control.rb +95 -0
- data/lib/proscenium/ui/breadcrumbs/mixins.css +83 -0
- data/lib/proscenium/ui/breadcrumbs.rb +72 -0
- data/lib/proscenium/ui/component.rb +11 -0
- data/lib/proscenium/ui/test.js +1 -0
- data/lib/proscenium/ui.rb +14 -0
- data/lib/proscenium/utils.rb +13 -0
- data/lib/proscenium/version.rb +5 -0
- data/lib/proscenium/view_component/css_modules.rb +11 -0
- data/lib/proscenium/view_component/react_component.rb +22 -0
- data/lib/proscenium/view_component/sideload.rb +4 -0
- data/lib/proscenium/view_component.rb +38 -0
- data/lib/proscenium.rb +70 -0
- 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,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,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
|