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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +10 -2
- data/Rakefile +29 -0
- data/docs/architecture.md +113 -404
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/compiler.rb +23 -15
- data/lib/funicular/helpers/picoruby_helper.rb +65 -3
- data/lib/funicular/middleware.rb +34 -9
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +94 -75
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +1 -1
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +3 -0
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +87 -4
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +1 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +24 -9
- data/mrblib/component.rb +172 -33
- data/mrblib/debug.rb +3 -0
- data/mrblib/differ.rb +47 -37
- data/mrblib/file_upload.rb +9 -1
- data/mrblib/form_builder.rb +21 -5
- data/mrblib/funicular.rb +97 -8
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +123 -29
- data/mrblib/model.rb +50 -0
- data/mrblib/patcher.rb +74 -8
- data/mrblib/router.rb +40 -3
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/sig/cable.rbs +1 -0
- data/sig/component.rbs +13 -5
- data/sig/funicular.rbs +14 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +21 -6
- data/sig/model.rbs +6 -1
- data/sig/patcher.rbs +4 -1
- data/sig/router.rbs +3 -2
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +6 -6
- metadata +47 -12
- data/docs/README.md +0 -419
- data/docs/advanced-features.md +0 -632
- data/docs/components-and-state.md +0 -539
- data/docs/data-fetching.md +0 -528
- data/docs/forms.md +0 -446
- data/docs/rails-integration.md +0 -426
- data/docs/realtime.md +0 -543
- data/docs/routing-and-navigation.md +0 -427
- data/docs/styling.md +0 -285
data/lib/funicular/compiler.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
24
|
-
|
|
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
|
data/lib/funicular/middleware.rb
CHANGED
|
@@ -49,13 +49,17 @@ module Funicular
|
|
|
49
49
|
|
|
50
50
|
begin
|
|
51
51
|
Rails.logger.info "Funicular: Source files changed, recompiling..."
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|