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,123 @@
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
+ plugin_registry = build_plugins
53
+ if Dir.exist?(@source_dir)
54
+ compiler = Compiler.new(
55
+ source_dir: @source_dir,
56
+ output_file: @output_file,
57
+ debug_mode: true,
58
+ logger: Rails.logger,
59
+ prepend_source_files: plugin_registry.local_source_files
60
+ )
61
+ compiler.compile
62
+ end
63
+ self.class.last_mtime = current_mtime
64
+ invalidate_asset_pipeline_cache
65
+ rescue => e
66
+ Rails.logger.error "Funicular compilation failed: #{e.message}"
67
+ ensure
68
+ self.class.compiling = false
69
+ end
70
+ end
71
+
72
+ # Force the asset pipeline to drop its cached fingerprint for app.mrb.
73
+ #
74
+ # Propshaft caches Asset instances (and memoizes #digest / #compiled_content)
75
+ # in LoadPath, and only refreshes them when its file watcher detects a change
76
+ # in a file whose extension is registered in Mime::EXTENSION_LOOKUP. The .mrb
77
+ # extension is not registered there, so when funicular rewrites app.mrb the
78
+ # Propshaft cache is never invalidated and asset_path('app.mrb') keeps
79
+ # returning the stale fingerprinted URL until the Rails process is restarted.
80
+ #
81
+ # We side-step that by invoking the cache sweeper directly after every
82
+ # successful recompile. This is a no-op if Propshaft is not in use.
83
+ def invalidate_asset_pipeline_cache
84
+ return unless Rails.application.respond_to?(:assets)
85
+
86
+ assets = Rails.application.assets
87
+ return unless assets.respond_to?(:load_path)
88
+
89
+ load_path = assets.load_path
90
+ return unless load_path.respond_to?(:cache_sweeper)
91
+
92
+ load_path.cache_sweeper.execute
93
+ end
94
+
95
+ def latest_source_mtime
96
+ source_files = Dir.glob(File.join(@source_dir, "**", "*.rb"))
97
+ plugin_files = plugin_source_files
98
+ all_files = source_files + plugin_files
99
+ return Time.at(0) if all_files.empty?
100
+
101
+ all_files.map { |f| File.mtime(f) }.max
102
+ end
103
+
104
+ def build_plugins
105
+ registry = Plugin::Registry.new(Rails.root)
106
+ return registry if registry.specs.empty?
107
+
108
+ registry.validate!
109
+ registry.sync_assets
110
+ registry
111
+ rescue Plugin::Error => e
112
+ Rails.logger.error "Funicular plugin compilation failed: #{e.message}"
113
+ Plugin::Registry.new(Rails.root)
114
+ end
115
+
116
+ def plugin_source_files
117
+ registry = Plugin::Registry.new(Rails.root)
118
+ registry.local_source_files + registry.specs.flat_map { |spec| spec.css_paths.map(&:to_s) }
119
+ rescue Plugin::Error
120
+ []
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "pathname"
5
+
6
+ module Funicular
7
+ module Plugin
8
+ BUILD_DIR = "app/assets/builds/funicular/plugins"
9
+ GROUP = :funicular
10
+
11
+ class Error < StandardError; end
12
+
13
+ class Project
14
+ attr_reader :root
15
+
16
+ def initialize(root)
17
+ @root = Pathname(root).expand_path
18
+ end
19
+
20
+ def assets_dir
21
+ root.join("assets")
22
+ end
23
+
24
+ def source_dir
25
+ root.join("lib")
26
+ end
27
+
28
+ def source_files
29
+ nested = Dir.glob(source_dir.join("*", "**", "*.rb").to_s).sort
30
+ top_level = Dir.glob(source_dir.join("*.rb").to_s).sort
31
+ nested + top_level
32
+ end
33
+
34
+ def css_paths
35
+ Dir.glob(assets_dir.join("*.css").to_s).sort.map { |path| Pathname(path) }
36
+ end
37
+ end
38
+
39
+ class Spec
40
+ attr_reader :bundler_spec
41
+
42
+ def initialize(bundler_spec)
43
+ @bundler_spec = bundler_spec
44
+ end
45
+
46
+ def root
47
+ @root ||= Pathname(bundler_spec.full_gem_path).expand_path
48
+ end
49
+
50
+ def project
51
+ @project ||= Project.new(root)
52
+ end
53
+
54
+ def name
55
+ bundler_spec.name
56
+ end
57
+
58
+ def css_paths
59
+ project.css_paths
60
+ end
61
+
62
+ def validate!
63
+ raise Error, "Missing Funicular plugin gem: #{root}" unless root.exist?
64
+ raise Error, "No Ruby source files found in #{project.source_dir}" if project.source_files.empty?
65
+
66
+ self
67
+ end
68
+ end
69
+
70
+ class Registry
71
+ attr_reader :rails_root
72
+
73
+ def initialize(rails_root)
74
+ @rails_root = Pathname(rails_root).expand_path
75
+ end
76
+
77
+ def specs
78
+ @specs ||= funicular_specs.map { |spec| Spec.new(spec) }
79
+ end
80
+
81
+ def sync_assets
82
+ build_root = rails_root.join(BUILD_DIR)
83
+ FileUtils.mkdir_p(build_root)
84
+
85
+ validated_specs.each do |spec|
86
+ target_dir = build_root.join(Plugin.safe_name(spec.name))
87
+ FileUtils.rm_rf(target_dir)
88
+ FileUtils.mkdir_p(target_dir)
89
+ spec.css_paths.each do |path|
90
+ FileUtils.cp(path, target_dir.join(File.basename(path))) if path.exist?
91
+ end
92
+ end
93
+ end
94
+
95
+ def local_source_files
96
+ validated_specs.flat_map { |spec| spec.project.source_files }
97
+ end
98
+
99
+ def asset_entries
100
+ validated_specs.flat_map do |spec|
101
+ safe_name = Plugin.safe_name(spec.name)
102
+ css = spec.css_paths.map { |path| File.basename(path) }
103
+ css.map { |file| { "type" => "css", "logical_path" => "funicular/plugins/#{safe_name}/#{file}" } }
104
+ end
105
+ end
106
+
107
+ def validate!
108
+ specs.each(&:validate!)
109
+ end
110
+
111
+ private
112
+
113
+ def funicular_specs
114
+ names = bundler_dependencies
115
+ .select { |dependency| dependency.groups.include?(GROUP) }
116
+ .map(&:name)
117
+ return [] if names.empty?
118
+
119
+ bundler_specs.select { |spec| names.include?(spec.name) }
120
+ end
121
+
122
+ def bundler_dependencies
123
+ require "bundler"
124
+
125
+ Bundler.definition.dependencies
126
+ rescue LoadError
127
+ raise Error, "Bundler is required to resolve Funicular plugin gems"
128
+ end
129
+
130
+ def bundler_specs
131
+ require "bundler"
132
+
133
+ Bundler.load.specs
134
+ rescue LoadError
135
+ raise Error, "Bundler is required to resolve Funicular plugin gems"
136
+ end
137
+
138
+ def validated_specs
139
+ specs.map(&:validate!)
140
+ end
141
+ end
142
+
143
+ def self.safe_name(name)
144
+ name.to_s.split("/").last.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "").downcase
145
+ end
146
+ end
147
+ 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,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Funicular
4
+ # Derives client-side validation rules from an ActiveModel/ActiveRecord
5
+ # class so they can be embedded in the JSON returned by a schema controller
6
+ # and reused by Funicular::Model on the client.
7
+ #
8
+ # Security model: validations are derived ONLY for the attribute names the
9
+ # caller passes (the schema's existing attribute allowlist), so nothing
10
+ # outside the already-public schema is ever introspected. A per-attribute
11
+ # `except` denylist suppresses specific validator kinds.
12
+ module Schema
13
+ # Validator kinds that have a client-side counterpart in
14
+ # Funicular::Model::Validations. Others (notably :uniqueness, which needs
15
+ # the database, and any custom validator) are skipped.
16
+ SUPPORTED_KINDS = %i[
17
+ presence absence length format numericality
18
+ inclusion exclusion acceptance confirmation
19
+ ].freeze
20
+
21
+ # Build a full schema hash, merging derived validations inline into each
22
+ # attribute entry (the shape Funicular::Model.load_schema consumes):
23
+ #
24
+ # Funicular::Schema.build(User,
25
+ # attributes: { "display_name" => { type: "string", readonly: false } },
26
+ # endpoints: { "update" => { method: "PATCH", path: "/users/:id" } },
27
+ # except: { username: [:format] })
28
+ # # => { attributes: { "display_name" => { type:, readonly:,
29
+ # # validations: { "presence" => true, "length" => {...} } } },
30
+ # # endpoints: {...} }
31
+ #
32
+ # Only the attributes you declare are introspected (allowlist); `except`
33
+ # drops specific kinds per attribute (denylist).
34
+ def self.build(model_class, attributes:, endpoints: {}, except: {})
35
+ merged = {}
36
+ attributes.each do |name, definition|
37
+ rules = rules_for(model_class, name, except_kinds(except, name))
38
+ merged[name] = rules.empty? ? definition : definition.merge(validations: rules)
39
+ end
40
+ { attributes: merged, endpoints: endpoints }
41
+ end
42
+
43
+ # Returns { "attr" => { "presence" => true, "length" => { "maximum" => 30 } } }
44
+ # for the given attribute names only. Useful when emitting validations as a
45
+ # separate block rather than inline (see #build for the inline form).
46
+ def self.validations_for(model_class, attribute_names, except: {})
47
+ result = {}
48
+ attribute_names.each do |name|
49
+ rules = rules_for(model_class, name, except_kinds(except, name))
50
+ result[name.to_s] = rules unless rules.empty?
51
+ end
52
+ result
53
+ end
54
+
55
+ # Derive the { kind => options } rules for a single attribute.
56
+ def self.rules_for(model_class, name, skip_kinds)
57
+ attr = name.to_sym
58
+ rules = {}
59
+ model_class.validators_on(attr).each do |validator|
60
+ kind = validator.kind
61
+ next unless SUPPORTED_KINDS.include?(kind)
62
+ next if skip_kinds.include?(kind)
63
+ # Conditional/context validators can't be evaluated on the client.
64
+ next if conditional?(validator.options)
65
+
66
+ serialized = serialize(kind, validator.options)
67
+ next if serialized.nil?
68
+ rules[kind.to_s] = serialized
69
+ end
70
+ rules
71
+ end
72
+
73
+ def self.except_kinds(except, name)
74
+ Array(except[name.to_sym] || except[name.to_s]).map(&:to_sym)
75
+ end
76
+
77
+ def self.conditional?(options)
78
+ options.key?(:if) || options.key?(:unless) || options.key?(:on)
79
+ end
80
+
81
+ def self.serialize(kind, options)
82
+ case kind
83
+ when :presence, :absence, :acceptance, :confirmation
84
+ true
85
+ when :length
86
+ serialize_length(options)
87
+ when :numericality
88
+ serialize_numericality(options)
89
+ when :inclusion, :exclusion
90
+ serialize_set(options)
91
+ when :format
92
+ RegexpTranslator.translate(options[:with])
93
+ end
94
+ end
95
+
96
+ def self.serialize_length(options)
97
+ opts = {}
98
+ [:minimum, :maximum, :is].each do |k|
99
+ opts[k.to_s] = options[k] if options[k].is_a?(Integer)
100
+ end
101
+ if (range = options[:in] || options[:within]).is_a?(Range)
102
+ opts["minimum"] = range.min
103
+ opts["maximum"] = range.max
104
+ end
105
+ opts.empty? ? nil : opts
106
+ end
107
+
108
+ def self.serialize_numericality(options)
109
+ opts = {}
110
+ opts["only_integer"] = true if options[:only_integer]
111
+ [:greater_than, :greater_than_or_equal_to, :equal_to,
112
+ :less_than, :less_than_or_equal_to, :other_than].each do |k|
113
+ opts[k.to_s] = options[k] if options[k].is_a?(Numeric)
114
+ end
115
+ opts.empty? ? true : opts
116
+ end
117
+
118
+ def self.serialize_set(options)
119
+ list = options[:in] || options[:within]
120
+ list = list.to_a if list.is_a?(Range)
121
+ return nil unless list.is_a?(Array)
122
+ return nil unless list.all? { |v| json_scalar?(v) }
123
+ { "in" => list }
124
+ end
125
+
126
+ def self.json_scalar?(value)
127
+ value.is_a?(String) || value.is_a?(Numeric) ||
128
+ value == true || value == false || value.nil?
129
+ end
130
+
131
+ # Best-effort translation of a Ruby Regexp into a JS-RegExp-compatible
132
+ # source. The client runs Regexp as a JS RegExp wrapper, so Ruby-only
133
+ # constructs are either translated (\A, \z, \Z anchors) or, when they have
134
+ # no safe JS equivalent, the validator is skipped with a warning.
135
+ module RegexpTranslator
136
+ # Substrings that JS RegExp cannot accept; presence means "skip".
137
+ INCOMPATIBLE = ['[[:', '\\h', '\\H', '\\G', '(?>'].freeze
138
+
139
+ def self.translate(regexp)
140
+ return nil unless regexp.is_a?(Regexp)
141
+
142
+ if (regexp.options & Regexp::EXTENDED) != 0
143
+ return skip("extended (x) mode")
144
+ end
145
+
146
+ source = regexp.source
147
+ if INCOMPATIBLE.any? { |token| source.include?(token) }
148
+ return skip("uses a construct unsupported by JS RegExp")
149
+ end
150
+
151
+ js_source = source.gsub('\\A', '^').gsub('\\z', '$').gsub('\\Z', '$')
152
+
153
+ flags = +''
154
+ flags << 'i' if (regexp.options & Regexp::IGNORECASE) != 0
155
+ flags << 'm' if (regexp.options & Regexp::MULTILINE) != 0
156
+
157
+ { 'with' => js_source, 'flags' => flags }
158
+ end
159
+
160
+ def self.skip(reason)
161
+ warn "[Funicular::Schema] skipping a format validator: #{reason}; " \
162
+ "declare it directly in the Funicular::Model if needed"
163
+ nil
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "../compiler"
5
+
6
+ module Funicular
7
+ module SSR
8
+ # Loads the PicoRuby (mrblib) framework runtime and the application's
9
+ # component classes into the CRuby process so the server can build VDOM
10
+ # and serialize it to HTML.
11
+ #
12
+ # The mrblib runtime is plain Ruby; the only JS access happens inside
13
+ # methods that SSR never calls (mount, patcher, fetch, history, ...).
14
+ # `Funicular.server = true` makes the few JS-touching entry points
15
+ # (Funicular.start, Router#start, FileUpload.mount, Debug) no-ops.
16
+ module Runtime
17
+ MRBLIB_DIR = File.expand_path("../../../mrblib", __dir__)
18
+
19
+ # Load order is by dependency at *class-body* evaluation time. Most
20
+ # files reference JS / other classes only inside methods, so only a
21
+ # few real dependencies exist (vdom before html_serializer; styles and
22
+ # vdom before component; component before error_boundary).
23
+ LOAD_ORDER = %w[
24
+ environment_inquirer
25
+ vdom
26
+ html_serializer
27
+ differ
28
+ patcher
29
+ styles
30
+ debug
31
+ component
32
+ error_boundary
33
+ router
34
+ 0_validations
35
+ 1_validators
36
+ model
37
+ store
38
+ store_singleton
39
+ store_collection
40
+ form_builder
41
+ http
42
+ cable
43
+ file_upload
44
+ funicular
45
+ ].freeze
46
+
47
+ class << self
48
+ # Load the framework runtime once. Idempotent.
49
+ def load_framework!
50
+ return if @framework_loaded
51
+
52
+ LOAD_ORDER.each do |name|
53
+ require File.join(MRBLIB_DIR, "#{name}.rb")
54
+ end
55
+ Funicular.server = true
56
+ @framework_loaded = true
57
+ end
58
+
59
+ # Load the application's component/model/store/initializer files in the
60
+ # canonical order. Running the initializer registers routes into
61
+ # Funicular.router (server-safe: Funicular.start skips all DOM work).
62
+ #
63
+ # Funicular model files are loaded so that the constants they define
64
+ # (e.g. Channel, Session) are available when the initializer evaluates
65
+ # `load_schemas({ Channel => "channel", ... })`. If a Funicular model
66
+ # shares a name with a same-named ActiveRecord model that Rails has
67
+ # already auto-loaded, Ruby raises TypeError (superclass mismatch).
68
+ # In that case we rescue and continue: the AR constant is already defined
69
+ # and is all that load_schemas needs (it ignores the hash on the server).
70
+ #
71
+ # Loaded once per process. Restart the server to pick up changes.
72
+ def boot!(source_dir)
73
+ load_framework!
74
+ return if @app_loaded
75
+
76
+ files = Funicular::Compiler.source_files(source_dir.to_s)
77
+ files.each do |file|
78
+ begin
79
+ Kernel.load(file)
80
+ rescue TypeError => e
81
+ # Funicular model name conflicts with an already-loaded AR model.
82
+ # The constant is already defined; safe to skip.
83
+ warn "[Funicular SSR] Skipped #{File.basename(file)}: #{e.message}"
84
+ end
85
+ end
86
+ @app_loaded = true
87
+ end
88
+
89
+ # Test/escape hatch: forget loaded application state so a different
90
+ # app (or a reload) can be booted. Does not unload the framework.
91
+ def reset_app!
92
+ @app_loaded = false
93
+ end
94
+
95
+ def framework_loaded?
96
+ !!@framework_loaded
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end