funicular 0.1.0 → 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 (89) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +24 -0
  3. data/README.md +10 -2
  4. data/Rakefile +29 -0
  5. data/docs/architecture.md +113 -404
  6. data/lib/funicular/assets/funicular.css +23 -0
  7. data/lib/funicular/compiler.rb +23 -15
  8. data/lib/funicular/helpers/picoruby_helper.rb +65 -3
  9. data/lib/funicular/middleware.rb +34 -9
  10. data/lib/funicular/plugin.rb +147 -0
  11. data/lib/funicular/schema.rb +167 -0
  12. data/lib/funicular/ssr/runtime.rb +101 -0
  13. data/lib/funicular/ssr.rb +51 -0
  14. data/lib/funicular/testing/node_runner.mjs +293 -0
  15. data/lib/funicular/testing/node_runner.rb +190 -0
  16. data/lib/funicular/testing.rb +22 -0
  17. data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
  18. data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
  19. data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
  20. data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
  21. data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
  22. data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
  23. data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
  24. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
  25. data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
  26. data/lib/funicular/version.rb +1 -1
  27. data/lib/funicular.rb +3 -0
  28. data/lib/generators/funicular/chat/chat_generator.rb +104 -0
  29. data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
  30. data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
  31. data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
  32. data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
  33. data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
  34. data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
  35. data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
  36. data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
  37. data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
  38. data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
  39. data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
  40. data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
  41. data/lib/tasks/funicular.rake +87 -4
  42. data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
  43. data/minitest/fixtures/funicular_app/initializer.rb +5 -0
  44. data/minitest/hydration_test.rb +87 -0
  45. data/minitest/plugin_test.rb +51 -0
  46. data/minitest/schema_test.rb +106 -0
  47. data/minitest/ssr_test.rb +94 -0
  48. data/minitest/validations_test.rb +183 -0
  49. data/mrbgem.rake +1 -0
  50. data/mrblib/0_validations.rb +206 -0
  51. data/mrblib/1_validators.rb +180 -0
  52. data/mrblib/cable.rb +24 -9
  53. data/mrblib/component.rb +172 -33
  54. data/mrblib/debug.rb +3 -0
  55. data/mrblib/differ.rb +47 -37
  56. data/mrblib/file_upload.rb +9 -1
  57. data/mrblib/form_builder.rb +21 -5
  58. data/mrblib/funicular.rb +97 -8
  59. data/mrblib/html_serializer.rb +121 -0
  60. data/mrblib/http.rb +123 -29
  61. data/mrblib/model.rb +50 -0
  62. data/mrblib/patcher.rb +74 -8
  63. data/mrblib/router.rb +40 -3
  64. data/mrblib/store.rb +304 -0
  65. data/mrblib/store_collection.rb +171 -0
  66. data/mrblib/store_singleton.rb +79 -0
  67. data/sig/cable.rbs +1 -0
  68. data/sig/component.rbs +13 -5
  69. data/sig/funicular.rbs +14 -1
  70. data/sig/html_serializer.rbs +20 -0
  71. data/sig/http.rbs +21 -6
  72. data/sig/model.rbs +6 -1
  73. data/sig/patcher.rbs +4 -1
  74. data/sig/router.rbs +3 -2
  75. data/sig/store.rbs +89 -0
  76. data/sig/store_collection.rbs +43 -0
  77. data/sig/store_singleton.rbs +19 -0
  78. data/sig/validations.rbs +103 -0
  79. data/sig/vdom.rbs +6 -6
  80. metadata +47 -12
  81. data/docs/README.md +0 -419
  82. data/docs/advanced-features.md +0 -632
  83. data/docs/components-and-state.md +0 -539
  84. data/docs/data-fetching.md +0 -528
  85. data/docs/forms.md +0 -446
  86. data/docs/rails-integration.md +0 -426
  87. data/docs/realtime.md +0 -543
  88. data/docs/routing-and-navigation.md +0 -427
  89. data/docs/styling.md +0 -285
@@ -11,13 +11,28 @@ module Funicular
11
11
  PICORBC_DIR = File.expand_path("vendor/picorbc", __dir__)
12
12
  PICORBC_JS = File.join(PICORBC_DIR, "picorbc.js")
13
13
 
14
- attr_reader :source_dir, :output_file, :debug_mode, :logger
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
15
27
 
16
- def initialize(source_dir:, output_file:, debug_mode: false, logger: nil)
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: [])
17
31
  @source_dir = source_dir
18
32
  @output_file = output_file
19
33
  @debug_mode = debug_mode
20
34
  @logger = logger
35
+ @prepend_source_files = prepend_source_files.map(&:to_s)
21
36
  end
22
37
 
23
38
  def compile
@@ -66,13 +81,7 @@ module Funicular
66
81
  end
67
82
 
68
83
  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
84
+ all_files = self.class.source_files(source_dir)
76
85
 
77
86
  if all_files.empty?
78
87
  raise "No Ruby files found in #{source_dir}"
@@ -84,15 +93,13 @@ module Funicular
84
93
  f.puts "ENV['FUNICULAR_ENV'] = '#{Rails.env}'"
85
94
  end
86
95
 
87
- @source_files = all_files
96
+ @source_files = prepend_source_files + all_files
88
97
  @env_file = env_file
89
98
  end
90
99
 
91
100
  def log(message)
92
101
  if logger
93
102
  logger.info(message)
94
- # Also output to stdout so logs are visible in terminal during development
95
- puts message if debug_mode
96
103
  else
97
104
  puts message
98
105
  end
@@ -102,14 +109,15 @@ module Funicular
102
109
  output_dir = File.dirname(output_file)
103
110
  FileUtils.mkdir_p(output_dir) unless Dir.exist?(output_dir)
104
111
 
105
- all_files = @source_files + [@env_file]
112
+ all_files = @source_files.dup
113
+ all_files << @env_file if @env_file
106
114
  argv = [node_command, PICORBC_JS]
107
115
  argv << "-g" if debug_mode
108
116
  argv += ["-o", output_file.to_s]
109
117
  argv += all_files.map(&:to_s)
110
118
 
111
- log "Compiling Funicular application..."
112
- log " Source: #{source_dir}"
119
+ log "Compiling Funicular Ruby..."
120
+ log " Source: #{source_dir}" if source_dir
113
121
  log " Input files:"
114
122
  all_files.each do |file|
115
123
  log " - #{file}"
@@ -11,6 +11,14 @@ module Funicular
11
11
  local_dist: "/picoruby/dist/init.iife.js"
12
12
  }.freeze
13
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
+
14
22
  # Renders a <script> tag that bootstraps PicoRuby.wasm.
15
23
  #
16
24
  # The source is determined by Funicular.configuration based on the
@@ -20,11 +28,65 @@ module Funicular
20
28
  # <%= picoruby_include_tag source: :cdn %>
21
29
  # <%= picoruby_include_tag source: :local_dist, defer: true %>
22
30
  #
23
- # Any extra options are passed straight through as HTML attributes.
24
- def picoruby_include_tag(source: nil, **options)
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)
25
36
  resolved_source = source ? source.to_sym : Funicular.configuration.source_for(Rails.env)
26
37
  src = picoruby_src_for(resolved_source)
27
- tag.script("", src: src, **options)
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}")
28
90
  end
29
91
 
30
92
  private
@@ -49,13 +49,17 @@ module Funicular
49
49
 
50
50
  begin
51
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
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
59
63
  self.class.last_mtime = current_mtime
60
64
  invalidate_asset_pipeline_cache
61
65
  rescue => e
@@ -90,9 +94,30 @@ module Funicular
90
94
 
91
95
  def latest_source_mtime
92
96
  source_files = Dir.glob(File.join(@source_dir, "**", "*.rb"))
93
- return Time.at(0) if source_files.empty?
97
+ plugin_files = plugin_source_files
98
+ all_files = source_files + plugin_files
99
+ return Time.at(0) if all_files.empty?
94
100
 
95
- source_files.map { |f| File.mtime(f) }.max
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
+ []
96
121
  end
97
122
  end
98
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,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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "ssr/runtime"
4
+
5
+ module Funicular
6
+ # Server-side rendering entry point.
7
+ #
8
+ # Usage (typically from a Rails controller / view helper):
9
+ #
10
+ # result = Funicular::SSR.render(
11
+ # path: request.path,
12
+ # state: { channels: Channel.all.as_json }
13
+ # )
14
+ # # result[:html] -> HTML string for the #app container
15
+ # # result[:state] -> data to embed as window.__FUNICULAR_STATE__
16
+ #
17
+ module SSR
18
+ # Render the component mapped to `path` to an HTML string, seeding it with
19
+ # server-provided `state`. Returns a hash:
20
+ # { html:, state:, component: }
21
+ # When no route matches, html is "" so the caller can fall back to plain
22
+ # client-side rendering (empty #app container).
23
+ def self.render(path:, state: {}, props: {}, source_dir: nil)
24
+ Runtime.boot!(source_dir || default_source_dir)
25
+
26
+ router = Funicular.router
27
+ raise "Funicular router is not configured; check app/funicular/initializer.rb" unless router
28
+
29
+ component_class, params = router.match(path)
30
+ return { html: "", state: {}, component: nil } unless component_class
31
+
32
+ instance = component_class.new(symbolize_keys(params).merge(props))
33
+ instance.seed_state(state)
34
+ html = Funicular::VDOM::HTMLSerializer.serialize(instance.build_vdom)
35
+
36
+ { html: html, state: state, component: component_class }
37
+ end
38
+
39
+ def self.default_source_dir
40
+ raise "source_dir is required outside Rails" unless defined?(Rails) && Rails.respond_to?(:root)
41
+ Rails.root.join("app", "funicular")
42
+ end
43
+
44
+ def self.symbolize_keys(hash)
45
+ return {} unless hash
46
+ out = {}
47
+ hash.each { |k, v| out[k.to_sym] = v }
48
+ out
49
+ end
50
+ end
51
+ end