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.
Files changed (115) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +66 -20
  4. data/Rakefile +103 -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/architecture.md +118 -0
  15. data/exe/funicular +32 -0
  16. data/lib/funicular/assets/funicular.css +23 -0
  17. data/lib/funicular/assets/funicular.rb +21 -0
  18. data/lib/funicular/assets/funicular_debug.css +73 -0
  19. data/lib/funicular/assets/funicular_debug.js +183 -0
  20. data/lib/funicular/commands/routes.rb +69 -0
  21. data/lib/funicular/compiler.rb +143 -0
  22. data/lib/funicular/configuration.rb +76 -0
  23. data/lib/funicular/helpers/picoruby_helper.rb +112 -0
  24. data/lib/funicular/middleware.rb +123 -0
  25. data/lib/funicular/plugin.rb +147 -0
  26. data/lib/funicular/railtie.rb +26 -0
  27. data/lib/funicular/route_parser.rb +137 -0
  28. data/lib/funicular/schema.rb +167 -0
  29. data/lib/funicular/ssr/runtime.rb +101 -0
  30. data/lib/funicular/ssr.rb +51 -0
  31. data/lib/funicular/testing/node_runner.mjs +293 -0
  32. data/lib/funicular/testing/node_runner.rb +190 -0
  33. data/lib/funicular/testing.rb +22 -0
  34. data/lib/funicular/vendor/picorbc/VERSION +1 -0
  35. data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
  36. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  37. data/lib/funicular/vendor/picoruby/VERSION +1 -0
  38. data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
  39. data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
  40. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  41. data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
  42. data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
  43. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  44. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  45. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  46. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  47. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  48. data/lib/funicular/version.rb +1 -1
  49. data/lib/funicular.rb +32 -1
  50. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  51. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  52. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  53. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  54. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  55. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  56. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  57. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  58. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  59. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  60. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  61. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  62. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  63. data/lib/tasks/funicular.rake +218 -0
  64. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  65. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  66. data/minitest/funicular_test.rb +13 -0
  67. data/minitest/hydration_test.rb +87 -0
  68. data/minitest/plugin_test.rb +51 -0
  69. data/minitest/schema_test.rb +106 -0
  70. data/minitest/ssr_test.rb +94 -0
  71. data/minitest/test_helper.rb +7 -0
  72. data/minitest/validations_test.rb +183 -0
  73. data/mrbgem.rake +16 -0
  74. data/mrblib/0_validations.rb +206 -0
  75. data/mrblib/1_validators.rb +180 -0
  76. data/mrblib/cable.rb +432 -0
  77. data/mrblib/component.rb +1050 -0
  78. data/mrblib/debug.rb +208 -0
  79. data/mrblib/differ.rb +254 -0
  80. data/mrblib/environment_inquirer.rb +34 -0
  81. data/mrblib/error_boundary.rb +125 -0
  82. data/mrblib/file_upload.rb +192 -0
  83. data/mrblib/form_builder.rb +300 -0
  84. data/mrblib/funicular.rb +245 -0
  85. data/mrblib/html_serializer.rb +121 -0
  86. data/mrblib/http.rb +183 -0
  87. data/mrblib/model.rb +196 -0
  88. data/mrblib/patcher.rb +269 -0
  89. data/mrblib/router.rb +266 -0
  90. data/mrblib/store.rb +304 -0
  91. data/mrblib/store_collection.rb +171 -0
  92. data/mrblib/store_singleton.rb +79 -0
  93. data/mrblib/styles.rb +83 -0
  94. data/mrblib/vdom.rb +273 -0
  95. data/sig/cable.rbs +66 -0
  96. data/sig/component.rbs +149 -0
  97. data/sig/debug.rbs +28 -0
  98. data/sig/differ.rbs +18 -0
  99. data/sig/environment_iquirer.rbs +10 -0
  100. data/sig/error_boundary.rbs +14 -0
  101. data/sig/file_upload.rbs +18 -0
  102. data/sig/form_builder.rbs +29 -0
  103. data/sig/funicular.rbs +24 -1
  104. data/sig/html_serializer.rbs +20 -0
  105. data/sig/http.rbs +37 -0
  106. data/sig/model.rbs +28 -0
  107. data/sig/patcher.rbs +18 -0
  108. data/sig/router.rbs +44 -0
  109. data/sig/store.rbs +89 -0
  110. data/sig/store_collection.rbs +43 -0
  111. data/sig/store_singleton.rbs +19 -0
  112. data/sig/styles.rbs +25 -0
  113. data/sig/validations.rbs +103 -0
  114. data/sig/vdom.rbs +59 -0
  115. 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