funicular 0.0.1 → 0.1.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 +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -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/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -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 +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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 +6404 -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/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -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 +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
data/exe/funicular
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "bundler/setup"
|
|
5
|
+
require "funicular"
|
|
6
|
+
|
|
7
|
+
command = ARGV[0]
|
|
8
|
+
|
|
9
|
+
case command
|
|
10
|
+
when "routes"
|
|
11
|
+
require_relative "../lib/funicular/commands/routes"
|
|
12
|
+
Funicular::Commands::Routes.new.execute
|
|
13
|
+
when "version", "-v", "--version"
|
|
14
|
+
puts Funicular::VERSION
|
|
15
|
+
when "help", "-h", "--help", nil
|
|
16
|
+
puts <<~HELP
|
|
17
|
+
Funicular CLI
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
funicular routes Show all Funicular routes
|
|
21
|
+
funicular version Show Funicular version
|
|
22
|
+
funicular help Show this help message
|
|
23
|
+
|
|
24
|
+
Options:
|
|
25
|
+
-h, --help Show help
|
|
26
|
+
-v, --version Show version
|
|
27
|
+
HELP
|
|
28
|
+
else
|
|
29
|
+
puts "Unknown command: #{command}"
|
|
30
|
+
puts "Run 'funicular help' for usage"
|
|
31
|
+
exit 1
|
|
32
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Exclude app/funicular from autoloading (PicoRuby.wasm code, not for CRuby)
|
|
4
|
+
Rails.autoloaders.main.ignore(Rails.root.join("app/funicular"))
|
|
5
|
+
|
|
6
|
+
# Choose where picoruby_include_tag loads PicoRuby.wasm from, per environment.
|
|
7
|
+
#
|
|
8
|
+
# Available sources:
|
|
9
|
+
# :local_debug - public/picoruby/debug/init.iife.js (debug build, larger, with symbols)
|
|
10
|
+
# :local_dist - public/picoruby/dist/init.iife.js (production build, smaller)
|
|
11
|
+
# :cdn - https://cdn.jsdelivr.net/npm/@picoruby/wasm-wasi@<version>/dist/init.iife.js
|
|
12
|
+
#
|
|
13
|
+
# Defaults:
|
|
14
|
+
# development -> :local_debug
|
|
15
|
+
# test -> :local_debug
|
|
16
|
+
# production -> :local_dist
|
|
17
|
+
#
|
|
18
|
+
# Funicular.configure do |config|
|
|
19
|
+
# config.production_source = :cdn
|
|
20
|
+
# # config.cdn_version = "4.0.0" # defaults to the version vendored in the gem
|
|
21
|
+
# end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/* Funicular Component Highlighter - Development Mode Only */
|
|
2
|
+
/* Highlights components with data-component attributes for easier debugging */
|
|
3
|
+
|
|
4
|
+
.funicular-debug-highlight {
|
|
5
|
+
outline: 2px solid var(--funicular-highlight-color, #00ff00) !important;
|
|
6
|
+
outline-offset: -2px !important;
|
|
7
|
+
position: relative !important;
|
|
8
|
+
box-shadow: inset 0 0 0 2px rgba(0, 255, 0, 0.2) !important;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
.funicular-debug-indicator {
|
|
12
|
+
position: absolute !important;
|
|
13
|
+
bottom: 0 !important;
|
|
14
|
+
right: 0 !important;
|
|
15
|
+
width: 0 !important;
|
|
16
|
+
height: 0 !important;
|
|
17
|
+
border-style: solid !important;
|
|
18
|
+
border-width: 0 0 14px 14px !important;
|
|
19
|
+
border-color: transparent transparent var(--funicular-highlight-color, #00ff00) transparent !important;
|
|
20
|
+
pointer-events: auto !important;
|
|
21
|
+
z-index: 999999 !important;
|
|
22
|
+
cursor: help !important;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.funicular-debug-indicator::before {
|
|
26
|
+
content: attr(data-tooltip) !important;
|
|
27
|
+
position: absolute !important;
|
|
28
|
+
bottom: -14px !important;
|
|
29
|
+
right: 0 !important;
|
|
30
|
+
background-color: rgba(0, 0, 0, 0.95) !important;
|
|
31
|
+
color: white !important;
|
|
32
|
+
padding: 6px 10px !important;
|
|
33
|
+
border-radius: 4px !important;
|
|
34
|
+
border: 1px solid var(--funicular-highlight-color, #00ff00) !important;
|
|
35
|
+
font-size: 13px !important;
|
|
36
|
+
font-family: 'Courier New', monospace !important;
|
|
37
|
+
white-space: nowrap !important;
|
|
38
|
+
opacity: 0 !important;
|
|
39
|
+
pointer-events: none !important;
|
|
40
|
+
transform: translateY(8px) !important;
|
|
41
|
+
transition: opacity 0.2s, transform 0.2s !important;
|
|
42
|
+
z-index: 1000000 !important;
|
|
43
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.5) !important;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.funicular-debug-indicator:hover::before {
|
|
47
|
+
opacity: 1 !important;
|
|
48
|
+
transform: translateY(0) !important;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/* PicoRuby DevTools Selected Component Highlight */
|
|
52
|
+
.picoruby-devtools-selected {
|
|
53
|
+
position: relative !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.picoruby-devtools-selected::after {
|
|
57
|
+
content: '' !important;
|
|
58
|
+
position: absolute !important;
|
|
59
|
+
top: 0 !important;
|
|
60
|
+
left: 0 !important;
|
|
61
|
+
right: 0 !important;
|
|
62
|
+
bottom: 0 !important;
|
|
63
|
+
background-color: rgba(0, 122, 204, 0.1) !important;
|
|
64
|
+
background-image: repeating-linear-gradient(
|
|
65
|
+
45deg,
|
|
66
|
+
transparent,
|
|
67
|
+
transparent 10px,
|
|
68
|
+
rgba(0, 122, 204, 0.15) 10px,
|
|
69
|
+
rgba(0, 122, 204, 0.15) 20px
|
|
70
|
+
) !important;
|
|
71
|
+
pointer-events: none !important;
|
|
72
|
+
z-index: 999998 !important;
|
|
73
|
+
}
|
|
@@ -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,135 @@
|
|
|
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
|
+
attr_reader :source_dir, :output_file, :debug_mode, :logger
|
|
15
|
+
|
|
16
|
+
def initialize(source_dir:, output_file:, debug_mode: false, logger: nil)
|
|
17
|
+
@source_dir = source_dir
|
|
18
|
+
@output_file = output_file
|
|
19
|
+
@debug_mode = debug_mode
|
|
20
|
+
@logger = logger
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def compile
|
|
24
|
+
check_picorbc_availability!
|
|
25
|
+
gather_source_files
|
|
26
|
+
compile_to_mrb
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def check_picorbc_availability!
|
|
32
|
+
unless File.exist?(PICORBC_JS)
|
|
33
|
+
raise PicorbcMissingError, <<~ERROR
|
|
34
|
+
Vendored picorbc not found at #{PICORBC_JS}.
|
|
35
|
+
|
|
36
|
+
The funicular gem ships picorbc.js + picorbc.wasm inside the gem
|
|
37
|
+
package. This file is missing, which likely means the gem was not
|
|
38
|
+
installed correctly. Try reinstalling:
|
|
39
|
+
|
|
40
|
+
bundle install --redownload
|
|
41
|
+
|
|
42
|
+
ERROR
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless node_command
|
|
46
|
+
raise NodeNotFoundError, <<~ERROR
|
|
47
|
+
Node.js executable not found.
|
|
48
|
+
|
|
49
|
+
Funicular compiles Ruby to .mrb using a WebAssembly build of picorbc
|
|
50
|
+
which is run via Node.js. Please install Node.js and ensure `node`
|
|
51
|
+
is on your PATH (or set the NODE environment variable).
|
|
52
|
+
ERROR
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def node_command
|
|
57
|
+
@node_command ||= ENV["NODE"] || which("node")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def which(cmd)
|
|
61
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |dir|
|
|
62
|
+
path = File.join(dir, cmd)
|
|
63
|
+
return path if File.executable?(path) && !File.directory?(path)
|
|
64
|
+
end
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def gather_source_files
|
|
69
|
+
models_files = Dir.glob(File.join(source_dir, "models", "**", "*.rb")).sort
|
|
70
|
+
components_files = Dir.glob(File.join(source_dir, "components", "**", "*.rb")).sort
|
|
71
|
+
initializer_files = Dir.glob(File.join(source_dir, "*_initializer.rb")).sort +
|
|
72
|
+
Dir.glob(File.join(source_dir, "initializer.rb")).sort
|
|
73
|
+
|
|
74
|
+
# Order: models -> components -> initializer
|
|
75
|
+
all_files = models_files + components_files + initializer_files
|
|
76
|
+
|
|
77
|
+
if all_files.empty?
|
|
78
|
+
raise "No Ruby files found in #{source_dir}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Create a small temp file for ENV setting
|
|
82
|
+
env_file = "#{output_file}.env.rb"
|
|
83
|
+
File.open(env_file, "w") do |f|
|
|
84
|
+
f.puts "ENV['FUNICULAR_ENV'] = '#{Rails.env}'"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@source_files = all_files
|
|
88
|
+
@env_file = env_file
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def log(message)
|
|
92
|
+
if logger
|
|
93
|
+
logger.info(message)
|
|
94
|
+
# Also output to stdout so logs are visible in terminal during development
|
|
95
|
+
puts message if debug_mode
|
|
96
|
+
else
|
|
97
|
+
puts message
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def compile_to_mrb
|
|
102
|
+
output_dir = File.dirname(output_file)
|
|
103
|
+
FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
|
|
104
|
+
|
|
105
|
+
all_files = @source_files + [@env_file]
|
|
106
|
+
argv = [node_command, PICORBC_JS]
|
|
107
|
+
argv << "-g" if debug_mode
|
|
108
|
+
argv += ["-o", output_file.to_s]
|
|
109
|
+
argv += all_files.map(&:to_s)
|
|
110
|
+
|
|
111
|
+
log "Compiling Funicular application..."
|
|
112
|
+
log " Source: #{source_dir}"
|
|
113
|
+
log " Input files:"
|
|
114
|
+
all_files.each do |file|
|
|
115
|
+
log " - #{file}"
|
|
116
|
+
end
|
|
117
|
+
log " Output: #{output_file}"
|
|
118
|
+
log " Debug mode: #{debug_mode}"
|
|
119
|
+
log " Files: #{all_files.size} files"
|
|
120
|
+
|
|
121
|
+
result = system(*argv)
|
|
122
|
+
|
|
123
|
+
unless result
|
|
124
|
+
raise "Failed to compile with picorbc. Command: #{Shellwords.join(argv)}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
log "Successfully compiled to #{output_file}"
|
|
128
|
+
ensure
|
|
129
|
+
# Keep temp file for debugging - set FUNICULAR_KEEP_TEMP=1 to inspect temp file
|
|
130
|
+
unless ENV['FUNICULAR_KEEP_TEMP']
|
|
131
|
+
File.delete(@env_file) if @env_file && File.exist?(@env_file)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
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,50 @@
|
|
|
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
|
+
# Renders a <script> tag that bootstraps PicoRuby.wasm.
|
|
15
|
+
#
|
|
16
|
+
# The source is determined by Funicular.configuration based on the
|
|
17
|
+
# current Rails environment, but can be overridden per call:
|
|
18
|
+
#
|
|
19
|
+
# <%= picoruby_include_tag %>
|
|
20
|
+
# <%= picoruby_include_tag source: :cdn %>
|
|
21
|
+
# <%= picoruby_include_tag source: :local_dist, defer: true %>
|
|
22
|
+
#
|
|
23
|
+
# Any extra options are passed straight through as HTML attributes.
|
|
24
|
+
def picoruby_include_tag(source: nil, **options)
|
|
25
|
+
resolved_source = source ? source.to_sym : Funicular.configuration.source_for(Rails.env)
|
|
26
|
+
src = picoruby_src_for(resolved_source)
|
|
27
|
+
tag.script("", src: src, **options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def picoruby_src_for(source)
|
|
33
|
+
if source == :cdn
|
|
34
|
+
version = Funicular.configuration.cdn_version
|
|
35
|
+
if version.nil? || version.empty?
|
|
36
|
+
raise ArgumentError,
|
|
37
|
+
"picoruby_include_tag source :cdn requires a version. " \
|
|
38
|
+
"Set Funicular.configuration.cdn_version or vendor the wasm artifacts via `rake funicular:vendor`."
|
|
39
|
+
end
|
|
40
|
+
format(CDN_URL_TEMPLATE, version: version)
|
|
41
|
+
elsif (path = LOCAL_PATHS[source])
|
|
42
|
+
path
|
|
43
|
+
else
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"Unknown picoruby source: #{source.inspect}. Expected one of #{Funicular::Configuration::SOURCES.inspect}"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|