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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +58 -20
  4. data/Rakefile +74 -2
  5. data/demo/keymap_editor.html +582 -0
  6. data/demo/test_cable.html +179 -0
  7. data/demo/test_chartjs.html +235 -0
  8. data/demo/test_component.html +201 -0
  9. data/demo/test_diff_patch.html +146 -0
  10. data/demo/test_error_boundary.html +284 -0
  11. data/demo/test_router.html +257 -0
  12. data/demo/test_vdom.html +100 -0
  13. data/demo/tic-tac-toe.html +201 -0
  14. data/docs/README.md +419 -0
  15. data/docs/advanced-features.md +632 -0
  16. data/docs/architecture.md +409 -0
  17. data/docs/components-and-state.md +539 -0
  18. data/docs/data-fetching.md +528 -0
  19. data/docs/forms.md +446 -0
  20. data/docs/rails-integration.md +426 -0
  21. data/docs/realtime.md +543 -0
  22. data/docs/routing-and-navigation.md +427 -0
  23. data/docs/styling.md +285 -0
  24. data/exe/funicular +32 -0
  25. data/lib/funicular/assets/funicular.rb +21 -0
  26. data/lib/funicular/assets/funicular_debug.css +73 -0
  27. data/lib/funicular/assets/funicular_debug.js +183 -0
  28. data/lib/funicular/commands/routes.rb +69 -0
  29. data/lib/funicular/compiler.rb +135 -0
  30. data/lib/funicular/configuration.rb +76 -0
  31. data/lib/funicular/helpers/picoruby_helper.rb +50 -0
  32. data/lib/funicular/middleware.rb +98 -0
  33. data/lib/funicular/railtie.rb +26 -0
  34. data/lib/funicular/route_parser.rb +137 -0
  35. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  37. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  38. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  39. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
  41. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  42. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  44. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  45. data/lib/funicular/version.rb +1 -1
  46. data/lib/funicular.rb +29 -1
  47. data/lib/tasks/funicular.rake +135 -0
  48. data/minitest/funicular_test.rb +13 -0
  49. data/minitest/test_helper.rb +7 -0
  50. data/mrbgem.rake +15 -0
  51. data/mrblib/cable.rb +417 -0
  52. data/mrblib/component.rb +911 -0
  53. data/mrblib/debug.rb +205 -0
  54. data/mrblib/differ.rb +244 -0
  55. data/mrblib/environment_inquirer.rb +34 -0
  56. data/mrblib/error_boundary.rb +125 -0
  57. data/mrblib/file_upload.rb +184 -0
  58. data/mrblib/form_builder.rb +284 -0
  59. data/mrblib/funicular.rb +156 -0
  60. data/mrblib/http.rb +89 -0
  61. data/mrblib/model.rb +146 -0
  62. data/mrblib/patcher.rb +203 -0
  63. data/mrblib/router.rb +229 -0
  64. data/mrblib/styles.rb +83 -0
  65. data/mrblib/vdom.rb +273 -0
  66. data/sig/cable.rbs +65 -0
  67. data/sig/component.rbs +141 -0
  68. data/sig/debug.rbs +28 -0
  69. data/sig/differ.rbs +18 -0
  70. data/sig/environment_iquirer.rbs +10 -0
  71. data/sig/error_boundary.rbs +14 -0
  72. data/sig/file_upload.rbs +18 -0
  73. data/sig/form_builder.rbs +29 -0
  74. data/sig/funicular.rbs +11 -1
  75. data/sig/http.rbs +22 -0
  76. data/sig/model.rbs +23 -0
  77. data/sig/patcher.rbs +15 -0
  78. data/sig/router.rbs +43 -0
  79. data/sig/styles.rbs +25 -0
  80. data/sig/vdom.rbs +59 -0
  81. 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