sandals 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +604 -0
- data/bin/sandals +8 -0
- data/lib/sandals/action_error.rb +41 -0
- data/lib/sandals/application.rb +114 -0
- data/lib/sandals/assets/TURBO-LICENSE +20 -0
- data/lib/sandals/assets/turbo.es2017-umd.js +7298 -0
- data/lib/sandals/binding_resolver.rb +77 -0
- data/lib/sandals/cli.rb +75 -0
- data/lib/sandals/code_reloader.rb +80 -0
- data/lib/sandals/event_dispatcher.rb +35 -0
- data/lib/sandals/html_renderer.rb +686 -0
- data/lib/sandals/server.rb +179 -0
- data/lib/sandals/snapshot_history.rb +34 -0
- data/lib/sandals/test_session.rb +352 -0
- data/lib/sandals/uploaded_file.rb +18 -0
- data/lib/sandals/version.rb +5 -0
- data/lib/sandals/view.rb +568 -0
- data/lib/sandals.rb +34 -0
- metadata +74 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class BindingResolver
|
|
5
|
+
UNSET = Object.new.freeze
|
|
6
|
+
Resolution = Data.define(:value, :error, :action)
|
|
7
|
+
|
|
8
|
+
def self.resolve(**options)
|
|
9
|
+
new(**options).resolve
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(model:, bind:, value:, error:, action:, default:)
|
|
13
|
+
@model = model
|
|
14
|
+
@bind = bind
|
|
15
|
+
@value = value
|
|
16
|
+
@error = error
|
|
17
|
+
@action = action
|
|
18
|
+
@default = default
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
return explicit_resolution unless binding_requested?
|
|
23
|
+
|
|
24
|
+
validate_binding
|
|
25
|
+
|
|
26
|
+
attribute = @bind.to_sym
|
|
27
|
+
writer = :"#{attribute}="
|
|
28
|
+
validate_accessors(attribute, writer)
|
|
29
|
+
|
|
30
|
+
Resolution.new(
|
|
31
|
+
@model.public_send(attribute),
|
|
32
|
+
resolved_error(attribute),
|
|
33
|
+
->(new_value) { @model.public_send(writer, new_value) }
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def binding_requested?
|
|
40
|
+
!@model.equal?(UNSET) || !@bind.equal?(UNSET)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def explicit_resolution
|
|
44
|
+
value = @value.equal?(UNSET) ? @default : @value
|
|
45
|
+
error = @error.equal?(UNSET) ? nil : @error
|
|
46
|
+
Resolution.new(value, error, @action)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def validate_binding
|
|
50
|
+
raise ArgumentError, "bind requires model" if @model.equal?(UNSET)
|
|
51
|
+
raise ArgumentError, "model requires bind" if @bind.equal?(UNSET)
|
|
52
|
+
raise ArgumentError, "bind must be a method name" unless @bind.respond_to?(:to_sym)
|
|
53
|
+
raise ArgumentError, "bind cannot be combined with value" unless @value.equal?(UNSET)
|
|
54
|
+
raise ArgumentError, "bind cannot be combined with an action" if @action
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_accessors(attribute, writer)
|
|
58
|
+
return if @model.respond_to?(attribute) && @model.respond_to?(writer)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError,
|
|
61
|
+
"#{@model.class} must respond to #{attribute} and #{writer}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def resolved_error(attribute)
|
|
65
|
+
return @error unless @error.equal?(UNSET)
|
|
66
|
+
return unless @model.respond_to?(:errors)
|
|
67
|
+
|
|
68
|
+
errors = @model.errors
|
|
69
|
+
return unless errors.respond_to?(:[])
|
|
70
|
+
|
|
71
|
+
error = errors[attribute]
|
|
72
|
+
return if error.respond_to?(:empty?) && error.empty?
|
|
73
|
+
|
|
74
|
+
error
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/sandals/cli.rb
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require_relative "../sandals"
|
|
5
|
+
|
|
6
|
+
module Sandals
|
|
7
|
+
class CLI
|
|
8
|
+
def self.start(arguments)
|
|
9
|
+
new(arguments).run
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(arguments)
|
|
13
|
+
@arguments = arguments.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
command = @arguments.shift
|
|
18
|
+
return usage unless command == "run"
|
|
19
|
+
|
|
20
|
+
run_app
|
|
21
|
+
rescue Interrupt
|
|
22
|
+
0
|
|
23
|
+
rescue StandardError => error
|
|
24
|
+
warn "sandals: #{error.message}"
|
|
25
|
+
1
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def run_app
|
|
31
|
+
options = { open: true, port: 0 }
|
|
32
|
+
parser = OptionParser.new do |opts|
|
|
33
|
+
opts.on("--no-open") { options[:open] = false }
|
|
34
|
+
opts.on("-pPORT", "--port=PORT", Integer) { |port| options[:port] = port }
|
|
35
|
+
end
|
|
36
|
+
parser.parse!(@arguments)
|
|
37
|
+
|
|
38
|
+
path = resolve_app(@arguments.shift || "app")
|
|
39
|
+
application = Sandals.load(path)
|
|
40
|
+
server = Server.new(application, port: options[:port], reload_path: path)
|
|
41
|
+
|
|
42
|
+
trap("INT") { server.stop }
|
|
43
|
+
puts "Sandals está corriendo #{path}"
|
|
44
|
+
puts server.url
|
|
45
|
+
open_browser(server.url) if options[:open]
|
|
46
|
+
server.start
|
|
47
|
+
0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def resolve_app(name)
|
|
51
|
+
candidates = [name]
|
|
52
|
+
candidates << "#{name}.rb" unless name.end_with?(".rb")
|
|
53
|
+
path = candidates.find { |candidate| File.file?(candidate) }
|
|
54
|
+
return path if path
|
|
55
|
+
|
|
56
|
+
raise ArgumentError, "could not find #{candidates.join(' or ')}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def open_browser(url)
|
|
60
|
+
command =
|
|
61
|
+
case RUBY_PLATFORM
|
|
62
|
+
when /darwin/ then ["open", url]
|
|
63
|
+
when /mingw|mswin/ then ["cmd", "/c", "start", url]
|
|
64
|
+
else ["xdg-open", url]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
system(*command, out: File::NULL, err: File::NULL)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def usage
|
|
71
|
+
warn "Usage: sandals run APP [--no-open] [--port PORT]"
|
|
72
|
+
1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class CodeReloader
|
|
5
|
+
ANSI_ESCAPE = /\e\[[0-9;]*m/
|
|
6
|
+
|
|
7
|
+
Result = Data.define(:version, :application, :error, :changed)
|
|
8
|
+
|
|
9
|
+
attr_reader :path
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@path = File.expand_path(path)
|
|
13
|
+
@signature = signature
|
|
14
|
+
@version = 1
|
|
15
|
+
@error = nil
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def check
|
|
20
|
+
@mutex.synchronize do
|
|
21
|
+
current_signature = signature
|
|
22
|
+
return result unless current_signature != @signature
|
|
23
|
+
|
|
24
|
+
@signature = current_signature
|
|
25
|
+
application = Sandals.load(path)
|
|
26
|
+
@version += 1
|
|
27
|
+
@error = nil
|
|
28
|
+
Result.new(@version, application, nil, true)
|
|
29
|
+
rescue SyntaxError, LoadError, StandardError => error
|
|
30
|
+
@error = error_details(error)
|
|
31
|
+
result(changed: true)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def result(changed: false)
|
|
38
|
+
Result.new(@version, nil, @error, changed)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def signature
|
|
42
|
+
stat = File.stat(path)
|
|
43
|
+
[stat.mtime.to_f, stat.size]
|
|
44
|
+
rescue Errno::ENOENT
|
|
45
|
+
[:missing]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def error_details(error)
|
|
49
|
+
{
|
|
50
|
+
class: error.class.to_s,
|
|
51
|
+
message: clean_message(error),
|
|
52
|
+
location: error_location(error)
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def error_location(error)
|
|
57
|
+
syntax_location =
|
|
58
|
+
clean_message(error).lines.first&.match(/\A(.+?):(\d+):/) if
|
|
59
|
+
error.is_a?(SyntaxError)
|
|
60
|
+
if syntax_location
|
|
61
|
+
source_path = syntax_location[1].delete_prefix("#{Dir.pwd}/")
|
|
62
|
+
return "#{source_path}:#{syntax_location[2]}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
locations = error.backtrace_locations || []
|
|
66
|
+
location = locations.find do |candidate|
|
|
67
|
+
!candidate.absolute_path.to_s.include?("/lib/sandals/")
|
|
68
|
+
end || locations.first
|
|
69
|
+
return path unless location
|
|
70
|
+
|
|
71
|
+
source_path = location.absolute_path || location.path
|
|
72
|
+
source_path = source_path.delete_prefix("#{Dir.pwd}/")
|
|
73
|
+
"#{source_path}:#{location.lineno}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def clean_message(error)
|
|
77
|
+
error.message.gsub(ANSI_ESCAPE, "")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class EventDispatcher
|
|
5
|
+
def initialize(controls)
|
|
6
|
+
@controls = controls
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def dispatch(events)
|
|
10
|
+
events.each { |event| dispatch_event(event) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def dispatch_event(event)
|
|
16
|
+
target = event.fetch("target")
|
|
17
|
+
name = event.fetch("event")
|
|
18
|
+
control = @controls.find { |candidate| candidate.id == target }
|
|
19
|
+
raise ArgumentError, "unknown control: #{target}" unless control
|
|
20
|
+
|
|
21
|
+
case [control, name]
|
|
22
|
+
in [TextField | NumberField | TextArea | Checkbox | Select, "change"]
|
|
23
|
+
control.change(event["value"])
|
|
24
|
+
in [FileField, "change"]
|
|
25
|
+
control.change(event.fetch("value"))
|
|
26
|
+
in [Submit, "submit"]
|
|
27
|
+
control.submit
|
|
28
|
+
in [Button, "click"]
|
|
29
|
+
control.activate
|
|
30
|
+
else
|
|
31
|
+
raise ArgumentError, "#{control.class} does not respond to #{name}"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|