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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -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/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6423 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- 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 +32 -1
- 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 +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -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/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- 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
|