sandals 0.1.0 → 0.1.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8706dd760aa94d3533ea6c2fb18bf8d265272a3c49250e1539300e8f2d960740
4
- data.tar.gz: 87590e396a533c73abc3eb6689c90481f301d84c885e344bb21148845f602baf
3
+ metadata.gz: 79d2b3d1395db396acd1aad4055141b75c955ca0ff0462e5fcf700db819af32e
4
+ data.tar.gz: 1cc17abdffbbc21540ada84f192cf9b1139c63c9422ef8152c2767ad6091473b
5
5
  SHA512:
6
- metadata.gz: b5cf4873ca1ebc024b7f45c744b3d7747ace37a867b6a0c9dfb9e85861398e3bf2ff99d7a431c1e7c9d063f1df1b5a65684918fa581ab2bfcf94915e2201fe81
7
- data.tar.gz: 1e17469e4cb0a3bdf878ffafc07d45e083e620a8410704c012c2cd524268e2b22d3786a6c7cf0db04e1b28707fea3198b121dbe5e0cc5b6bf7323d6d039a3705
6
+ metadata.gz: 6002d0c758974e5e797e153ebd50c0422b2a706b8329d6831cd44c7a13daabee0c9f9bd55aa49b3389e7798c00195ad92156f8f205bc74afb1fcab7395c0df90
7
+ data.tar.gz: 075fb63042fd98661f107e249ff3af9c3c82bf203d44f7a58c937a8bdf7ac71fc235d4ae22246972b26ecb8cde00a246f9968bb343a9ffb9306a4f509e52d32f
data/README.md CHANGED
@@ -19,12 +19,84 @@ Sandals requires Ruby 3.1 or later.
19
19
 
20
20
  ```sh
21
21
  gem install sandals
22
- sandals run app.rb
22
+ sandals new hello
23
+ sandals run hello.rb
23
24
  ```
24
25
 
25
26
  The server listens on `127.0.0.1`. All required assets are included in the gem,
26
27
  so a local app does not need an internet connection.
27
28
 
29
+ ## Quickstart
30
+
31
+ Create your first application:
32
+
33
+ ```sh
34
+ sandals new hello
35
+ ```
36
+
37
+ This creates two files:
38
+
39
+ ```text
40
+ hello.rb
41
+ hello_test.rb
42
+ ```
43
+
44
+ `hello.rb` is a small application with a text field and a greeting. Run it:
45
+
46
+ ```sh
47
+ sandals run hello.rb
48
+ ```
49
+
50
+ Keep Sandals running and open `hello.rb` in your editor. Change:
51
+
52
+ ```ruby
53
+ title "Hello"
54
+ ```
55
+
56
+ to:
57
+
58
+ ```ruby
59
+ title "Hello from Ruby"
60
+ ```
61
+
62
+ Save the file. The browser reloads automatically and shows the change.
63
+
64
+ If the file contains a syntax or load error, Sandals keeps the last valid
65
+ version running and reports the error with its location. Fix the file and save
66
+ it again; the application recovers automatically.
67
+
68
+ The generated test interacts with the application through visible labels and
69
+ text. Run it with:
70
+
71
+ ```sh
72
+ ruby hello_test.rb
73
+ ```
74
+
75
+ The test fills in `"Your name"` and verifies that the greeting appears. It uses
76
+ Minitest, which is installed with Sandals.
77
+
78
+ ## Startup errors
79
+
80
+ Sandals reports startup failures with the error type, application file, and
81
+ line number instead of printing an internal framework backtrace:
82
+
83
+ ```text
84
+ sandals: SyntaxError at app.rb:12
85
+ app.rb:12: syntax error, unexpected end-of-input
86
+ ```
87
+
88
+ Missing dependencies are reported in the same way:
89
+
90
+ ```text
91
+ sandals: LoadError at app.rb:1
92
+ cannot load such file -- sqlite3
93
+ Add the missing dependency to the app and try again.
94
+ ```
95
+
96
+ Sandals also evaluates the initial view before opening the server, so an
97
+ exception during the first render points to the relevant line in the
98
+ application.
99
+
28
100
  ## The experience
29
101
 
30
102
  An application should normally fit in a single file:
@@ -17,15 +17,7 @@ module Sandals
17
17
  end
18
18
 
19
19
  def location
20
- locations = original_error.backtrace_locations || []
21
- relevant_location = locations.find do |candidate|
22
- !candidate.absolute_path.to_s.include?("/lib/sandals/")
23
- end || locations.first
24
- return "Location unavailable" unless relevant_location
25
-
26
- path = relevant_location.absolute_path || relevant_location.path
27
- path = path.delete_prefix("#{Dir.pwd}/")
28
- "#{path}:#{relevant_location.lineno}"
20
+ ErrorDetails.for(original_error).fetch(:location)
29
21
  end
30
22
  end
31
23
 
data/lib/sandals/cli.rb CHANGED
@@ -5,6 +5,16 @@ require_relative "../sandals"
5
5
 
6
6
  module Sandals
7
7
  class CLI
8
+ class InitialAppError < StandardError
9
+ attr_reader :original_error, :path
10
+
11
+ def initialize(original_error, path)
12
+ @original_error = original_error
13
+ @path = path
14
+ super(original_error.message)
15
+ end
16
+ end
17
+
8
18
  def self.start(arguments)
9
19
  new(arguments).run
10
20
  end
@@ -15,18 +25,58 @@ module Sandals
15
25
 
16
26
  def run
17
27
  command = @arguments.shift
18
- return usage unless command == "run"
19
-
20
- run_app
21
- rescue Interrupt
22
- 0
28
+ case command
29
+ when "run" then run_app
30
+ when "new" then new_app
31
+ else usage
32
+ end
33
+ rescue InitialAppError => error
34
+ report_initial_app_error(error)
23
35
  rescue StandardError => error
24
36
  warn "sandals: #{error.message}"
25
37
  1
38
+ rescue Interrupt
39
+ 0
26
40
  end
27
41
 
28
42
  private
29
43
 
44
+ def new_app
45
+ name = @arguments.shift
46
+ raise ArgumentError, "missing app name" unless name
47
+ raise ArgumentError, "unexpected arguments: #{@arguments.join(' ')}" unless @arguments.empty?
48
+
49
+ base_name = name.delete_suffix(".rb")
50
+ unless base_name.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_-]*\z/)
51
+ raise ArgumentError, "app name must contain only letters, numbers, underscores, or hyphens"
52
+ end
53
+
54
+ app_path = "#{base_name}.rb"
55
+ test_path = "#{base_name}_test.rb"
56
+ existing_path = [app_path, test_path].find { |path| File.exist?(path) }
57
+ raise ArgumentError, "#{existing_path} already exists" if existing_path
58
+
59
+ title = base_name.split(/[_-]+/).map(&:capitalize).join(" ")
60
+ File.open(app_path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
61
+ file.write(app_template(title))
62
+ end
63
+ File.open(test_path, File::WRONLY | File::CREAT | File::EXCL, 0o644) do |file|
64
+ file.write(test_template(base_name))
65
+ end
66
+
67
+ puts "Created #{app_path}"
68
+ puts "Created #{test_path}"
69
+ puts
70
+ puts "Run the app:"
71
+ puts " sandals run #{app_path}"
72
+ puts
73
+ puts "Run the test:"
74
+ puts " ruby #{test_path}"
75
+ 0
76
+ rescue Errno::EEXIST
77
+ raise ArgumentError, "could not create app because a generated file already exists"
78
+ end
79
+
30
80
  def run_app
31
81
  options = { open: true, port: 0 }
32
82
  parser = OptionParser.new do |opts|
@@ -36,17 +86,36 @@ module Sandals
36
86
  parser.parse!(@arguments)
37
87
 
38
88
  path = resolve_app(@arguments.shift || "app")
39
- application = Sandals.load(path)
89
+ application = load_initial_application(path)
40
90
  server = Server.new(application, port: options[:port], reload_path: path)
41
91
 
42
92
  trap("INT") { server.stop }
43
- puts "Sandals está corriendo #{path}"
93
+ puts "Sandals is running #{path}"
44
94
  puts server.url
45
95
  open_browser(server.url) if options[:open]
46
96
  server.start
47
97
  0
48
98
  end
49
99
 
100
+ def load_initial_application(path)
101
+ application = Sandals.load(path)
102
+ application.render(development_reload: true)
103
+ application
104
+ rescue SyntaxError, LoadError, StandardError => error
105
+ raise InitialAppError.new(error, File.expand_path(path))
106
+ end
107
+
108
+ def report_initial_app_error(error)
109
+ original = error.original_error
110
+ details = ErrorDetails.for(original, fallback_path: error.path)
111
+ warn "sandals: #{details.fetch(:class)} at #{details.fetch(:location)}"
112
+ warn details.fetch(:message)
113
+ if original.is_a?(LoadError)
114
+ warn "Add the missing dependency to the app and try again."
115
+ end
116
+ 1
117
+ end
118
+
50
119
  def resolve_app(name)
51
120
  candidates = [name]
52
121
  candidates << "#{name}.rb" unless name.end_with?(".rb")
@@ -67,8 +136,51 @@ module Sandals
67
136
  system(*command, out: File::NULL, err: File::NULL)
68
137
  end
69
138
 
139
+ def app_template(title)
140
+ <<~RUBY
141
+ require "sandals"
142
+
143
+ Sandals.app title: "#{title}" do
144
+ @name = ""
145
+
146
+ view do
147
+ stack do
148
+ title "#{title}"
149
+
150
+ text_field "Your name", value: @name do |value|
151
+ @name = value
152
+ end
153
+
154
+ para "Hello, \#{@name}!" unless @name.empty?
155
+ end
156
+ end
157
+ end
158
+ RUBY
159
+ end
160
+
161
+ def test_template(base_name)
162
+ <<~RUBY
163
+ require "minitest/autorun"
164
+ require_relative "#{base_name}"
165
+
166
+ class GeneratedAppTest < Minitest::Test
167
+ def test_greets_the_user
168
+ session = Sandals.test(Sandals.application)
169
+
170
+ session.field("Your name").fill("Ada")
171
+
172
+ assert session.text?("Hello, Ada!")
173
+ end
174
+ end
175
+ RUBY
176
+ end
177
+
70
178
  def usage
71
- warn "Usage: sandals run APP [--no-open] [--port PORT]"
179
+ warn <<~USAGE
180
+ Usage:
181
+ sandals new NAME
182
+ sandals run APP [--no-open] [--port PORT]
183
+ USAGE
72
184
  1
73
185
  end
74
186
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Sandals
4
4
  class CodeReloader
5
- ANSI_ESCAPE = /\e\[[0-9;]*m/
6
-
7
- Result = Data.define(:version, :application, :error, :changed)
5
+ Result = Struct.new(:version, :application, :error, :changed)
8
6
 
9
7
  attr_reader :path
10
8
 
@@ -46,35 +44,7 @@ module Sandals
46
44
  end
47
45
 
48
46
  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, "")
47
+ ErrorDetails.for(error, fallback_path: path)
78
48
  end
79
49
  end
80
50
  end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Sandals
4
+ class ErrorDetails
5
+ ANSI_ESCAPE = /\e\[[0-9;]*m/
6
+
7
+ def self.for(error, fallback_path: nil)
8
+ new(error, fallback_path:).to_h
9
+ end
10
+
11
+ def initialize(error, fallback_path:)
12
+ @error = error
13
+ @fallback_path = fallback_path
14
+ end
15
+
16
+ def to_h
17
+ {
18
+ class: @error.class.to_s,
19
+ message: clean_message,
20
+ location: error_location
21
+ }
22
+ end
23
+
24
+ private
25
+
26
+ def error_location
27
+ path, line = syntax_location || backtrace_location
28
+ return @fallback_path || "Location unavailable" unless path
29
+
30
+ relative_location(path, line)
31
+ end
32
+
33
+ def syntax_location
34
+ return unless @error.is_a?(SyntaxError)
35
+
36
+ line = clean_message.lines.first&.[](/:(\d+):/, 1)
37
+ [@error.path || @fallback_path, line] if line
38
+ end
39
+
40
+ def backtrace_location
41
+ locations = @error.backtrace_locations || []
42
+ location =
43
+ locations.find { |candidate| same_path?(candidate, @fallback_path) } ||
44
+ locations.find { |candidate| !sandals_path?(candidate) } ||
45
+ locations.first
46
+
47
+ [location&.absolute_path || location&.path, location&.lineno]
48
+ end
49
+
50
+ def same_path?(location, path)
51
+ path && File.expand_path(location.absolute_path || location.path) == File.expand_path(path)
52
+ end
53
+
54
+ def sandals_path?(location)
55
+ location.absolute_path.to_s.include?("/lib/sandals/")
56
+ end
57
+
58
+ def relative_location(path, line)
59
+ source_path = path.delete_prefix("#{Dir.pwd}/")
60
+ "#{source_path}:#{line}"
61
+ end
62
+
63
+ def clean_message
64
+ @error.message.gsub(ANSI_ESCAPE, "").strip
65
+ end
66
+ end
67
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sandals
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/sandals.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "sandals/version"
4
+ require_relative "sandals/error_details"
4
5
  require_relative "sandals/binding_resolver"
5
6
  require_relative "sandals/action_error"
6
7
  require_relative "sandals/uploaded_file"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sandals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benito Serna
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: minitest
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '6.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '6.0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: webrick
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -42,6 +56,7 @@ files:
42
56
  - lib/sandals/binding_resolver.rb
43
57
  - lib/sandals/cli.rb
44
58
  - lib/sandals/code_reloader.rb
59
+ - lib/sandals/error_details.rb
45
60
  - lib/sandals/event_dispatcher.rb
46
61
  - lib/sandals/html_renderer.rb
47
62
  - lib/sandals/server.rb