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
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Funicular
4
+ class Middleware
5
+ class << self
6
+ attr_accessor :last_mtime, :compiling, :mutex
7
+
8
+ def reset!
9
+ @last_mtime = nil
10
+ @compiling = false
11
+ @mutex = Mutex.new
12
+ end
13
+ end
14
+
15
+ # Initialize class state
16
+ reset!
17
+
18
+ def initialize(app)
19
+ @app = app
20
+ @source_dir = Rails.root.join("app", "funicular")
21
+ @output_file = Rails.root.join("app", "assets", "builds", "app.mrb")
22
+ end
23
+
24
+ def call(env)
25
+ recompile_if_needed if should_check_recompile?
26
+ @app.call(env)
27
+ end
28
+
29
+ private
30
+
31
+ def should_check_recompile?
32
+ Rails.env.development? && Dir.exist?(@source_dir)
33
+ end
34
+
35
+ def recompile_if_needed
36
+ current_mtime = latest_source_mtime
37
+
38
+ # Skip if already compiling or if no changes detected
39
+ return if self.class.compiling
40
+ return if self.class.last_mtime && current_mtime <= self.class.last_mtime
41
+
42
+ self.class.mutex.synchronize do
43
+ # Double-check inside the lock
44
+ return if self.class.compiling
45
+ return if self.class.last_mtime && current_mtime <= self.class.last_mtime
46
+
47
+ self.class.compiling = true
48
+ end
49
+
50
+ begin
51
+ Rails.logger.info "Funicular: Source files changed, recompiling..."
52
+ compiler = Compiler.new(
53
+ source_dir: @source_dir,
54
+ output_file: @output_file,
55
+ debug_mode: true,
56
+ logger: Rails.logger
57
+ )
58
+ compiler.compile
59
+ self.class.last_mtime = current_mtime
60
+ invalidate_asset_pipeline_cache
61
+ rescue => e
62
+ Rails.logger.error "Funicular compilation failed: #{e.message}"
63
+ ensure
64
+ self.class.compiling = false
65
+ end
66
+ end
67
+
68
+ # Force the asset pipeline to drop its cached fingerprint for app.mrb.
69
+ #
70
+ # Propshaft caches Asset instances (and memoizes #digest / #compiled_content)
71
+ # in LoadPath, and only refreshes them when its file watcher detects a change
72
+ # in a file whose extension is registered in Mime::EXTENSION_LOOKUP. The .mrb
73
+ # extension is not registered there, so when funicular rewrites app.mrb the
74
+ # Propshaft cache is never invalidated and asset_path('app.mrb') keeps
75
+ # returning the stale fingerprinted URL until the Rails process is restarted.
76
+ #
77
+ # We side-step that by invoking the cache sweeper directly after every
78
+ # successful recompile. This is a no-op if Propshaft is not in use.
79
+ def invalidate_asset_pipeline_cache
80
+ return unless Rails.application.respond_to?(:assets)
81
+
82
+ assets = Rails.application.assets
83
+ return unless assets.respond_to?(:load_path)
84
+
85
+ load_path = assets.load_path
86
+ return unless load_path.respond_to?(:cache_sweeper)
87
+
88
+ load_path.cache_sweeper.execute
89
+ end
90
+
91
+ def latest_source_mtime
92
+ source_files = Dir.glob(File.join(@source_dir, "**", "*.rb"))
93
+ return Time.at(0) if source_files.empty?
94
+
95
+ source_files.map { |f| File.mtime(f) }.max
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Funicular
6
+ class Railtie < Rails::Railtie
7
+ railtie_name :funicular
8
+
9
+ initializer "funicular.middleware" do |app|
10
+ if Rails.env.development?
11
+ app.middleware.use Funicular::Middleware
12
+ end
13
+ end
14
+
15
+ initializer "funicular.helpers" do
16
+ ActiveSupport.on_load(:action_view) do
17
+ require "funicular/helpers/picoruby_helper"
18
+ include Funicular::Helpers::PicorubyHelper
19
+ end
20
+ end
21
+
22
+ rake_tasks do
23
+ load "tasks/funicular.rake"
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Funicular
4
+ class RouteParser
5
+ attr_reader :routes
6
+
7
+ def initialize(source_file)
8
+ @source_file = source_file
9
+ @routes = []
10
+ end
11
+
12
+ def parse
13
+ return [] unless File.exist?(@source_file)
14
+
15
+ content = File.read(@source_file)
16
+ lines = content.split("\n")
17
+
18
+ lines.each do |line|
19
+ # Skip comments and empty lines
20
+ trimmed = line.strip
21
+ next if trimmed.empty? || trimmed.start_with?('#')
22
+
23
+ # Parse router.get/post/put/patch/delete lines
24
+ if trimmed.include?('router.')
25
+ parse_route_line(trimmed)
26
+ end
27
+ end
28
+
29
+ @routes
30
+ end
31
+
32
+ private
33
+
34
+ def parse_route_line(line)
35
+ # Extract HTTP method
36
+ method = nil
37
+ ['get', 'post', 'put', 'patch', 'delete', 'add_route'].each do |m|
38
+ if line.include?("router.#{m}")
39
+ method = m
40
+ break
41
+ end
42
+ end
43
+
44
+ return unless method
45
+
46
+ # Extract path (between first pair of quotes)
47
+ path = extract_quoted_string(line)
48
+ return unless path
49
+
50
+ # Extract component name (after 'to:' or as second argument)
51
+ component = nil
52
+ if line.include?('to:')
53
+ to_idx = line.index('to:')
54
+ if to_idx
55
+ # Component name is after 'to:'
56
+ after_to = line[to_idx + 3..-1].strip
57
+ # Find where component name ends (comma or paren)
58
+ end_idx = find_first_of(after_to, [',', ')'])
59
+ if end_idx
60
+ component = after_to[0...end_idx].strip
61
+ else
62
+ component = after_to.strip
63
+ end
64
+ end
65
+ elsif method == 'add_route'
66
+ # Old style: router.add_route('/path', ComponentName)
67
+ # Find second comma-separated value
68
+ first_comma = line.index(',')
69
+ if first_comma
70
+ after_comma = line[first_comma + 1..-1].strip
71
+ # Component name ends at comma or paren
72
+ end_idx = find_first_of(after_comma, [',', ')'])
73
+ if end_idx
74
+ component = after_comma[0...end_idx].strip
75
+ else
76
+ component = after_comma.strip
77
+ end
78
+ end
79
+ end
80
+
81
+ return unless component
82
+
83
+ # Extract helper name (after 'as:')
84
+ helper_name = nil
85
+ if line.include?('as:')
86
+ as_idx = line.index('as:')
87
+ if as_idx
88
+ after_as = line[as_idx + 3..-1]
89
+ helper_str = extract_quoted_string(after_as)
90
+ helper_name = helper_str ? "#{helper_str}_path" : nil
91
+ end
92
+ end
93
+
94
+ # Add route
95
+ @routes << {
96
+ method: method == 'add_route' ? 'GET' : method.upcase,
97
+ path: path,
98
+ component: component,
99
+ helper: helper_name
100
+ }
101
+ end
102
+
103
+ def extract_quoted_string(text)
104
+ # Find first quoted string (single or double quotes)
105
+ start_idx = nil
106
+ quote_char = nil
107
+
108
+ text.each_char.with_index do |char, idx|
109
+ if char == '"' || char == "'"
110
+ if start_idx.nil?
111
+ start_idx = idx
112
+ quote_char = char
113
+ elsif char == quote_char
114
+ # Found closing quote
115
+ return text[(start_idx + 1)...idx]
116
+ end
117
+ end
118
+ end
119
+
120
+ nil
121
+ end
122
+
123
+ def find_first_of(text, chars)
124
+ # Find index of first occurrence of any char in chars array
125
+ min_idx = nil
126
+
127
+ chars.each do |char|
128
+ idx = text.index(char)
129
+ if idx
130
+ min_idx = idx if min_idx.nil? || idx < min_idx
131
+ end
132
+ end
133
+
134
+ min_idx
135
+ end
136
+ end
137
+ end
@@ -0,0 +1 @@
1
+ 4.0.0