funicular 0.0.1 → 0.2.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 +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Funicular Component Highlighter - Development Mode Only
|
|
2
|
+
// Highlights components with data-component attributes for easier debugging
|
|
3
|
+
|
|
4
|
+
(function() {
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
//console.log('[Funicular Debug] Script loaded');
|
|
8
|
+
|
|
9
|
+
// Color themes
|
|
10
|
+
const COLORS = {
|
|
11
|
+
green: '#00ff00', // Fluorescent green (default)
|
|
12
|
+
yellow: '#ffff00', // Lemon yellow
|
|
13
|
+
pink: '#ff69b4', // Hot pink
|
|
14
|
+
cyan: '#00ffff' // Cyan/sky blue
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const DEBUG_CLASS = 'funicular-debug-highlight';
|
|
18
|
+
|
|
19
|
+
// Get color dynamically (called each time we need it)
|
|
20
|
+
const getHighlightColor = () => {
|
|
21
|
+
const rubyColor = window.FUNICULAR_DEBUG_COLOR;
|
|
22
|
+
|
|
23
|
+
//console.log('[Funicular Debug] Ruby color setting:', rubyColor);
|
|
24
|
+
|
|
25
|
+
// If nil/undefined/null, disable the feature
|
|
26
|
+
if (rubyColor === null || rubyColor === undefined || rubyColor === 'nil') {
|
|
27
|
+
//console.log('[Funicular Debug] Debug highlighting is disabled (color is nil)');
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return COLORS[rubyColor] || COLORS.green;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function initComponentHighlighter() {
|
|
35
|
+
const highlightColor = getHighlightColor();
|
|
36
|
+
|
|
37
|
+
// Exit early if debug highlighting is disabled
|
|
38
|
+
if (highlightColor === null) {
|
|
39
|
+
//console.log('[Funicular Debug] Component highlighting disabled');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//console.log('[Funicular Debug] Initializing component highlighter with color:', highlightColor);
|
|
44
|
+
|
|
45
|
+
// Find all elements with data-component attribute
|
|
46
|
+
const components = document.querySelectorAll('[data-component]');
|
|
47
|
+
//console.log(`[Funicular Debug] Found ${components.length} components with data-component`);
|
|
48
|
+
|
|
49
|
+
components.forEach(element => {
|
|
50
|
+
highlightElement(element, highlightColor);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Use MutationObserver to watch for dynamic DOM changes
|
|
55
|
+
function watchForComponents() {
|
|
56
|
+
//console.log('[Funicular Debug] Starting MutationObserver...');
|
|
57
|
+
|
|
58
|
+
const observer = new MutationObserver((mutations) => {
|
|
59
|
+
const highlightColor = getHighlightColor();
|
|
60
|
+
if (highlightColor === null) return;
|
|
61
|
+
|
|
62
|
+
mutations.forEach((mutation) => {
|
|
63
|
+
mutation.addedNodes.forEach((node) => {
|
|
64
|
+
if (node.nodeType === 1) { // Element node
|
|
65
|
+
// Check if the added node itself has data-component
|
|
66
|
+
if (node.hasAttribute && node.hasAttribute('data-component')) {
|
|
67
|
+
//console.log('[Funicular Debug] New component detected:', node.getAttribute('data-component'));
|
|
68
|
+
highlightElement(node, highlightColor);
|
|
69
|
+
}
|
|
70
|
+
// Check children
|
|
71
|
+
const children = node.querySelectorAll ? node.querySelectorAll('[data-component]') : [];
|
|
72
|
+
children.forEach(child => {
|
|
73
|
+
//console.log('[Funicular Debug] New child component detected:', child.getAttribute('data-component'));
|
|
74
|
+
highlightElement(child, highlightColor);
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
observer.observe(document.body, {
|
|
82
|
+
childList: true,
|
|
83
|
+
subtree: true
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function highlightElement(element, highlightColor) {
|
|
88
|
+
const componentName = element.getAttribute('data-component');
|
|
89
|
+
|
|
90
|
+
let tooltipText = componentName;
|
|
91
|
+
if (element.id) {
|
|
92
|
+
tooltipText += ` id=${element.id}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Check if indicator already exists
|
|
96
|
+
const existingIndicator = element.querySelector('.funicular-debug-indicator');
|
|
97
|
+
if (existingIndicator) {
|
|
98
|
+
// Update tooltip if it's different
|
|
99
|
+
if (existingIndicator.getAttribute('data-tooltip') !== tooltipText) {
|
|
100
|
+
existingIndicator.setAttribute('data-tooltip', tooltipText);
|
|
101
|
+
}
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add debug class
|
|
106
|
+
if (!element.classList.contains(DEBUG_CLASS)) {
|
|
107
|
+
element.classList.add(DEBUG_CLASS);
|
|
108
|
+
// Apply color to the element
|
|
109
|
+
element.style.setProperty('--funicular-highlight-color', highlightColor);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Create corner triangle indicator
|
|
113
|
+
const indicator = document.createElement('div');
|
|
114
|
+
indicator.className = 'funicular-debug-indicator';
|
|
115
|
+
indicator.setAttribute('data-tooltip', tooltipText);
|
|
116
|
+
indicator.style.setProperty('--funicular-highlight-color', highlightColor);
|
|
117
|
+
|
|
118
|
+
// Position relative wrapper if needed
|
|
119
|
+
const position = window.getComputedStyle(element).position;
|
|
120
|
+
if (position === 'static') {
|
|
121
|
+
element.style.position = 'relative';
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
element.appendChild(indicator);
|
|
125
|
+
|
|
126
|
+
// Re-add indicator after a short delay if it gets removed
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
const check = element.querySelector('.funicular-debug-indicator');
|
|
129
|
+
if (!check) {
|
|
130
|
+
const newIndicator = document.createElement('div');
|
|
131
|
+
newIndicator.className = 'funicular-debug-indicator';
|
|
132
|
+
newIndicator.setAttribute('data-tooltip', tooltipText);
|
|
133
|
+
newIndicator.style.setProperty('--funicular-highlight-color', highlightColor);
|
|
134
|
+
element.appendChild(newIndicator);
|
|
135
|
+
}
|
|
136
|
+
}, 100);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Initialize on DOM ready
|
|
140
|
+
if (document.readyState === 'loading') {
|
|
141
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
142
|
+
//console.log('[Funicular Debug] DOMContentLoaded event');
|
|
143
|
+
initComponentHighlighter();
|
|
144
|
+
watchForComponents();
|
|
145
|
+
});
|
|
146
|
+
} else {
|
|
147
|
+
//console.log('[Funicular Debug] DOM already loaded');
|
|
148
|
+
initComponentHighlighter();
|
|
149
|
+
watchForComponents();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Reinitialize on turbo:load for Turbo/Hotwire apps
|
|
153
|
+
document.addEventListener('turbo:load', () => {
|
|
154
|
+
//console.log('[Funicular Debug] turbo:load event');
|
|
155
|
+
initComponentHighlighter();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// Reinitialize on turbolinks:load for older Turbolinks
|
|
159
|
+
document.addEventListener('turbolinks:load', () => {
|
|
160
|
+
//console.log('[Funicular Debug] turbolinks:load event');
|
|
161
|
+
initComponentHighlighter();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// Expose a manual trigger function for console debugging
|
|
165
|
+
window.funicularDebug = {
|
|
166
|
+
init: initComponentHighlighter,
|
|
167
|
+
checkDOM: () => {
|
|
168
|
+
//console.log('Document readyState:', document.readyState);
|
|
169
|
+
//console.log('Body:', document.body);
|
|
170
|
+
//console.log('App element:', document.getElementById('app'));
|
|
171
|
+
const components = document.querySelectorAll('[data-component]');
|
|
172
|
+
//console.log('Components found:', components.length);
|
|
173
|
+
//components.forEach(c => console.log(' -', c.getAttribute('data-component'), c));
|
|
174
|
+
},
|
|
175
|
+
currentColor: () => {
|
|
176
|
+
//console.log('Current color from Ruby:', window.FUNICULAR_DEBUG_COLOR);
|
|
177
|
+
//console.log('Resolved color:', getHighlightColor());
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const currentColor = getHighlightColor();
|
|
182
|
+
//console.log('[Funicular Debug] Current color:', currentColor ? window.FUNICULAR_DEBUG_COLOR : 'disabled');
|
|
183
|
+
})();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../route_parser"
|
|
4
|
+
|
|
5
|
+
module Funicular
|
|
6
|
+
module Commands
|
|
7
|
+
class Routes
|
|
8
|
+
def execute
|
|
9
|
+
# Check if we're in a Rails app
|
|
10
|
+
unless File.exist?("config/application.rb")
|
|
11
|
+
puts "Error: Not in a Rails application directory"
|
|
12
|
+
exit 1
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Load Rails environment if not already loaded (CLI usage)
|
|
16
|
+
unless defined?(Rails)
|
|
17
|
+
require "./config/environment"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
source_dir = Rails.root.join("app", "funicular")
|
|
21
|
+
initializer_file = source_dir.join("initializer.rb")
|
|
22
|
+
|
|
23
|
+
unless File.exist?(initializer_file)
|
|
24
|
+
puts "No Funicular routes found (#{initializer_file} does not exist)"
|
|
25
|
+
exit 0
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
parser = RouteParser.new(initializer_file)
|
|
29
|
+
routes = parser.parse
|
|
30
|
+
|
|
31
|
+
if routes.empty?
|
|
32
|
+
puts "No routes defined"
|
|
33
|
+
exit 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
print_routes_table(routes)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def print_routes_table(routes)
|
|
42
|
+
# Calculate column widths
|
|
43
|
+
method_width = [routes.map { |r| r[:method].length }.max, 6].max
|
|
44
|
+
path_width = [routes.map { |r| r[:path].length }.max, 4].max
|
|
45
|
+
component_width = [routes.map { |r| r[:component].length }.max, 9].max
|
|
46
|
+
helper_width = [routes.map { |r| (r[:helper] || "").length }.max, 10].max
|
|
47
|
+
|
|
48
|
+
# Print header
|
|
49
|
+
puts format_row("Method", "Path", "Component", "Helper",
|
|
50
|
+
method_width, path_width, component_width, helper_width)
|
|
51
|
+
puts "-" * (method_width + path_width + component_width + helper_width + 12)
|
|
52
|
+
|
|
53
|
+
# Print routes
|
|
54
|
+
routes.each do |route|
|
|
55
|
+
puts format_row(route[:method], route[:path], route[:component],
|
|
56
|
+
route[:helper] || "",
|
|
57
|
+
method_width, path_width, component_width, helper_width)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
puts
|
|
61
|
+
puts "Total: #{routes.length} route#{routes.length == 1 ? '' : 's'}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_row(method, path, component, helper, mw, pw, cw, hw)
|
|
65
|
+
"%-#{mw}s %-#{pw}s %-#{cw}s %-#{hw}s" % [method, path, component, helper]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Funicular
|
|
6
|
+
class Compiler
|
|
7
|
+
class NodeNotFoundError < StandardError; end
|
|
8
|
+
class PicorbcMissingError < StandardError; end
|
|
9
|
+
|
|
10
|
+
# picorbc.js + picorbc.wasm bundled into the gem at build time by `rake copy_wasm`.
|
|
11
|
+
PICORBC_DIR = File.expand_path("vendor/picorbc", __dir__)
|
|
12
|
+
PICORBC_JS = File.join(PICORBC_DIR, "picorbc.js")
|
|
13
|
+
|
|
14
|
+
# Ordered list of application source files under app/funicular/.
|
|
15
|
+
# Order matters: models -> stores -> components -> initializer, so that
|
|
16
|
+
# later files can reference classes defined earlier. Shared by the .mrb
|
|
17
|
+
# compiler (client build) and the SSR runtime (server class loading).
|
|
18
|
+
def self.source_files(source_dir)
|
|
19
|
+
models_files = Dir.glob(File.join(source_dir, "models", "**", "*.rb")).sort
|
|
20
|
+
stores_files = Dir.glob(File.join(source_dir, "stores", "**", "*.rb")).sort
|
|
21
|
+
components_files = Dir.glob(File.join(source_dir, "components", "**", "*.rb")).sort
|
|
22
|
+
initializer_files = Dir.glob(File.join(source_dir, "*_initializer.rb")).sort +
|
|
23
|
+
Dir.glob(File.join(source_dir, "initializer.rb")).sort
|
|
24
|
+
|
|
25
|
+
models_files + stores_files + components_files + initializer_files
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
attr_reader :source_dir, :output_file, :debug_mode, :logger, :prepend_source_files
|
|
29
|
+
|
|
30
|
+
def initialize(source_dir:, output_file:, debug_mode: false, logger: nil, prepend_source_files: [])
|
|
31
|
+
@source_dir = source_dir
|
|
32
|
+
@output_file = output_file
|
|
33
|
+
@debug_mode = debug_mode
|
|
34
|
+
@logger = logger
|
|
35
|
+
@prepend_source_files = prepend_source_files.map(&:to_s)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def compile
|
|
39
|
+
check_picorbc_availability!
|
|
40
|
+
gather_source_files
|
|
41
|
+
compile_to_mrb
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def check_picorbc_availability!
|
|
47
|
+
unless File.exist?(PICORBC_JS)
|
|
48
|
+
raise PicorbcMissingError, <<~ERROR
|
|
49
|
+
Vendored picorbc not found at #{PICORBC_JS}.
|
|
50
|
+
|
|
51
|
+
The funicular gem ships picorbc.js + picorbc.wasm inside the gem
|
|
52
|
+
package. This file is missing, which likely means the gem was not
|
|
53
|
+
installed correctly. Try reinstalling:
|
|
54
|
+
|
|
55
|
+
bundle install --redownload
|
|
56
|
+
|
|
57
|
+
ERROR
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless node_command
|
|
61
|
+
raise NodeNotFoundError, <<~ERROR
|
|
62
|
+
Node.js executable not found.
|
|
63
|
+
|
|
64
|
+
Funicular compiles Ruby to .mrb using a WebAssembly build of picorbc
|
|
65
|
+
which is run via Node.js. Please install Node.js and ensure `node`
|
|
66
|
+
is on your PATH (or set the NODE environment variable).
|
|
67
|
+
ERROR
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def node_command
|
|
72
|
+
@node_command ||= ENV["NODE"] || which("node")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def which(cmd)
|
|
76
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
77
|
+
path = File.join(dir, cmd)
|
|
78
|
+
return path if File.executable?(path) && !File.directory?(path)
|
|
79
|
+
end
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def gather_source_files
|
|
84
|
+
all_files = self.class.source_files(source_dir)
|
|
85
|
+
|
|
86
|
+
if all_files.empty?
|
|
87
|
+
raise "No Ruby files found in #{source_dir}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Create a small temp file for ENV setting
|
|
91
|
+
env_file = "#{output_file}.env.rb"
|
|
92
|
+
File.open(env_file, "w") do |f|
|
|
93
|
+
f.puts "ENV['FUNICULAR_ENV'] = '#{Rails.env}'"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
@source_files = prepend_source_files + all_files
|
|
97
|
+
@env_file = env_file
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def log(message)
|
|
101
|
+
if logger
|
|
102
|
+
logger.info(message)
|
|
103
|
+
else
|
|
104
|
+
puts message
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def compile_to_mrb
|
|
109
|
+
output_dir = File.dirname(output_file)
|
|
110
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
111
|
+
|
|
112
|
+
all_files = @source_files.dup
|
|
113
|
+
all_files << @env_file if @env_file
|
|
114
|
+
argv = [node_command, PICORBC_JS]
|
|
115
|
+
argv << "-g" if debug_mode
|
|
116
|
+
argv += ["-o", output_file.to_s]
|
|
117
|
+
argv += all_files.map(&:to_s)
|
|
118
|
+
|
|
119
|
+
log "Compiling Funicular Ruby..."
|
|
120
|
+
log " Source: #{source_dir}" if source_dir
|
|
121
|
+
log " Input files:"
|
|
122
|
+
all_files.each do |file|
|
|
123
|
+
log " - #{file}"
|
|
124
|
+
end
|
|
125
|
+
log " Output: #{output_file}"
|
|
126
|
+
log " Debug mode: #{debug_mode}"
|
|
127
|
+
log " Files: #{all_files.size} files"
|
|
128
|
+
|
|
129
|
+
result = system(*argv)
|
|
130
|
+
|
|
131
|
+
unless result
|
|
132
|
+
raise "Failed to compile with picorbc. Command: #{Shellwords.join(argv)}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
log "Successfully compiled to #{output_file}"
|
|
136
|
+
ensure
|
|
137
|
+
# Keep temp file for debugging - set FUNICULAR_KEEP_TEMP=1 to inspect temp file
|
|
138
|
+
unless ENV['FUNICULAR_KEEP_TEMP']
|
|
139
|
+
File.delete(@env_file) if @env_file && File.exist?(@env_file)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Funicular
|
|
4
|
+
# Holds runtime configuration for the funicular gem.
|
|
5
|
+
#
|
|
6
|
+
# The most important setting is which PicoRuby.wasm artifact the
|
|
7
|
+
# +picoruby_include_tag+ helper should reference, per Rails environment.
|
|
8
|
+
#
|
|
9
|
+
# Possible source values:
|
|
10
|
+
#
|
|
11
|
+
# :local_debug - serve the debug build vendored into the gem and
|
|
12
|
+
# installed under public/picoruby/debug/
|
|
13
|
+
# :local_dist - serve the production (dist) build vendored into the
|
|
14
|
+
# gem and installed under public/picoruby/dist/
|
|
15
|
+
# :cdn - load from jsDelivr at
|
|
16
|
+
# https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@VERSION/dist/init.iife.js
|
|
17
|
+
#
|
|
18
|
+
# Defaults are sensible for most apps:
|
|
19
|
+
#
|
|
20
|
+
# development -> :local_debug
|
|
21
|
+
# test -> :local_debug
|
|
22
|
+
# production -> :local_dist
|
|
23
|
+
#
|
|
24
|
+
# Switch production to :cdn if you would rather not host the wasm yourself.
|
|
25
|
+
class Configuration
|
|
26
|
+
SOURCES = %i[local_debug local_dist cdn].freeze
|
|
27
|
+
|
|
28
|
+
attr_reader :development_source, :test_source, :production_source
|
|
29
|
+
attr_writer :cdn_version
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@development_source = :local_debug
|
|
33
|
+
@test_source = :local_debug
|
|
34
|
+
@production_source = :local_dist
|
|
35
|
+
@cdn_version = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def development_source=(value)
|
|
39
|
+
@development_source = validate_source!(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_source=(value)
|
|
43
|
+
@test_source = validate_source!(value)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def production_source=(value)
|
|
47
|
+
@production_source = validate_source!(value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Returns the configured source for a given Rails environment name.
|
|
51
|
+
# Unknown environments fall back to development_source.
|
|
52
|
+
def source_for(env_name)
|
|
53
|
+
case env_name.to_s
|
|
54
|
+
when "production" then @production_source
|
|
55
|
+
when "test" then @test_source
|
|
56
|
+
else @development_source
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The @picoruby/wasm-wasi version to use when source is :cdn.
|
|
61
|
+
# Falls back to the version of the wasm artifacts vendored in this gem.
|
|
62
|
+
def cdn_version
|
|
63
|
+
@cdn_version || Funicular.vendored_wasm_version
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validate_source!(value)
|
|
69
|
+
sym = value.to_sym
|
|
70
|
+
unless SOURCES.include?(sym)
|
|
71
|
+
raise ArgumentError, "Invalid Funicular source: #{value.inspect}. Expected one of #{SOURCES.inspect}"
|
|
72
|
+
end
|
|
73
|
+
sym
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Funicular
|
|
4
|
+
module Helpers
|
|
5
|
+
# View helpers exposed to ActionView through Funicular::Railtie.
|
|
6
|
+
module PicorubyHelper
|
|
7
|
+
CDN_URL_TEMPLATE = "https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@%<version>s/dist/init.iife.js"
|
|
8
|
+
|
|
9
|
+
LOCAL_PATHS = {
|
|
10
|
+
local_debug: "/picoruby/debug/init.iife.js",
|
|
11
|
+
local_dist: "/picoruby/dist/init.iife.js"
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
# Minimal CSS the gem ships for class names it emits itself (e.g.
|
|
15
|
+
# FormBuilder error states). Read once; see assets/funicular.css.
|
|
16
|
+
BASE_CSS_PATH = File.expand_path("../assets/funicular.css", __dir__)
|
|
17
|
+
|
|
18
|
+
def self.base_css
|
|
19
|
+
@base_css ||= File.read(BASE_CSS_PATH)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Renders a <script> tag that bootstraps PicoRuby.wasm.
|
|
23
|
+
#
|
|
24
|
+
# The source is determined by Funicular.configuration based on the
|
|
25
|
+
# current Rails environment, but can be overridden per call:
|
|
26
|
+
#
|
|
27
|
+
# <%= picoruby_include_tag %>
|
|
28
|
+
# <%= picoruby_include_tag source: :cdn %>
|
|
29
|
+
# <%= picoruby_include_tag source: :local_dist, defer: true %>
|
|
30
|
+
#
|
|
31
|
+
# Also emits Funicular's small base stylesheet (so gem-emitted class names
|
|
32
|
+
# such as form error states render without host-CSS setup); pass
|
|
33
|
+
# base_styles: false to skip it. Any extra options become HTML attributes
|
|
34
|
+
# on the <script> tag.
|
|
35
|
+
def picoruby_include_tag(source: nil, base_styles: true, **options)
|
|
36
|
+
resolved_source = source ? source.to_sym : Funicular.configuration.source_for(Rails.env)
|
|
37
|
+
src = picoruby_src_for(resolved_source)
|
|
38
|
+
script = tag.script("", src: src, **options)
|
|
39
|
+
return script unless base_styles
|
|
40
|
+
|
|
41
|
+
style = tag.style(PicorubyHelper.base_css.html_safe, "data-funicular-base": "")
|
|
42
|
+
safe_join([style, script])
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Renders the SSR #app container with the server-rendered HTML inside.
|
|
46
|
+
#
|
|
47
|
+
# <%= funicular_app_container(@ssr[:html]) %>
|
|
48
|
+
#
|
|
49
|
+
# On the client, Funicular hydrates this element instead of rebuilding
|
|
50
|
+
# it. Pass an empty string (the default) to fall back to plain CSR.
|
|
51
|
+
def funicular_app_container(html = "", id: "app", **options)
|
|
52
|
+
content_tag(:div, raw(html.to_s), { id: id }.merge(options))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Emits the initial state for client hydration as a global JS variable.
|
|
56
|
+
#
|
|
57
|
+
# <%= funicular_state_tag(@ssr[:state]) %>
|
|
58
|
+
# # => <script>window.__FUNICULAR_STATE__ = {...};</script>
|
|
59
|
+
#
|
|
60
|
+
# The JSON is escaped so it cannot break out of the <script> element.
|
|
61
|
+
def funicular_state_tag(state = {})
|
|
62
|
+
json = JSON.generate(state || {})
|
|
63
|
+
# Escape characters that could break out of the <script> element or
|
|
64
|
+
# confuse the HTML parser, using JS unicode escapes that remain valid
|
|
65
|
+
# JSON/JS string content.
|
|
66
|
+
safe = json.gsub("<", "\\u003c").gsub(">", "\\u003e").gsub("&", "\\u0026")
|
|
67
|
+
raw("<script>window.__FUNICULAR_STATE__ = #{safe};</script>")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Renders registered Funicular plugin browser assets.
|
|
71
|
+
#
|
|
72
|
+
# Plugins are gems in the Gemfile :funicular group. Their Ruby sources
|
|
73
|
+
# are compiled into app.mrb before the application sources; this helper
|
|
74
|
+
# emits browser assets such as CSS.
|
|
75
|
+
def funicular_plugin_include_tags
|
|
76
|
+
registry = Funicular::Plugin::Registry.new(Rails.root)
|
|
77
|
+
tags = registry.asset_entries.map do |entry|
|
|
78
|
+
logical_path = entry.fetch("logical_path")
|
|
79
|
+
if entry["type"] == "css"
|
|
80
|
+
stylesheet_link_tag(logical_path, "data-turbo-track": "reload")
|
|
81
|
+
else
|
|
82
|
+
tag.script("", type: "application/x-mrb", src: asset_path(logical_path), data: { funicular_plugin: true })
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
safe_join(tags)
|
|
86
|
+
rescue Funicular::Plugin::Error => e
|
|
87
|
+
raise e if Rails.env.production?
|
|
88
|
+
|
|
89
|
+
tag.comment("Funicular plugin assets skipped: #{e.message}")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def picoruby_src_for(source)
|
|
95
|
+
if source == :cdn
|
|
96
|
+
version = Funicular.configuration.cdn_version
|
|
97
|
+
if version.nil? || version.empty?
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"picoruby_include_tag source :cdn requires a version. " \
|
|
100
|
+
"Set Funicular.configuration.cdn_version or vendor the wasm artifacts via `rake funicular:vendor`."
|
|
101
|
+
end
|
|
102
|
+
format(CDN_URL_TEMPLATE, version: version)
|
|
103
|
+
elsif (path = LOCAL_PATHS[source])
|
|
104
|
+
path
|
|
105
|
+
else
|
|
106
|
+
raise ArgumentError,
|
|
107
|
+
"Unknown picoruby source: #{source.inspect}. Expected one of #{Funicular::Configuration::SOURCES.inspect}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|