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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 8706dd760aa94d3533ea6c2fb18bf8d265272a3c49250e1539300e8f2d960740
|
|
4
|
+
data.tar.gz: 87590e396a533c73abc3eb6689c90481f301d84c885e344bb21148845f602baf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b5cf4873ca1ebc024b7f45c744b3d7747ace37a867b6a0c9dfb9e85861398e3bf2ff99d7a431c1e7c9d063f1df1b5a65684918fa581ab2bfcf94915e2201fe81
|
|
7
|
+
data.tar.gz: 1e17469e4cb0a3bdf878ffafc07d45e083e620a8410704c012c2cd524268e2b22d3786a6c7cf0db04e1b28707fea3198b121dbe5e0cc5b6bf7323d6d039a3705
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sandals contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
1
|
+
# Sandals
|
|
2
|
+
|
|
3
|
+
Sandals is a small environment for running interactive Ruby applications whose
|
|
4
|
+
structure can be understood by reading their code.
|
|
5
|
+
|
|
6
|
+
The central idea is simple:
|
|
7
|
+
|
|
8
|
+
> Describe a small tool in Ruby, run it immediately, and be able to understand
|
|
9
|
+
> what it displays, what data it keeps, and what happens with each action.
|
|
10
|
+
|
|
11
|
+
Sandals is not trying to be another web framework or to recreate all of Shoes.
|
|
12
|
+
It also does not try to hide a complex application behind a magical DSL. Its
|
|
13
|
+
purpose is to find a clear, concise, and expressive way to write small
|
|
14
|
+
applications that both people and AI can create and understand.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Sandals requires Ruby 3.1 or later.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
gem install sandals
|
|
22
|
+
sandals run app.rb
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The server listens on `127.0.0.1`. All required assets are included in the gem,
|
|
26
|
+
so a local app does not need an internet connection.
|
|
27
|
+
|
|
28
|
+
## The experience
|
|
29
|
+
|
|
30
|
+
An application should normally fit in a single file:
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
require "sandals"
|
|
34
|
+
|
|
35
|
+
Sandals.app title: "Counter" do
|
|
36
|
+
@count = 0
|
|
37
|
+
|
|
38
|
+
view do
|
|
39
|
+
stack do
|
|
40
|
+
title "Counter"
|
|
41
|
+
para "Total: #{@count}"
|
|
42
|
+
|
|
43
|
+
button "Increment" do
|
|
44
|
+
@count += 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Run it with:
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
sandals run counter.rb
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Sandals starts a local Ruby process, opens the application in the browser, and
|
|
58
|
+
handles communication between the interface and its actions. To the person
|
|
59
|
+
creating or using the application, it should feel like running a Ruby program,
|
|
60
|
+
not configuring a web project.
|
|
61
|
+
|
|
62
|
+
## Layout and content
|
|
63
|
+
|
|
64
|
+
`stack` arranges elements vertically. `flow` arranges them horizontally and
|
|
65
|
+
allows them to wrap onto the next line:
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
view do
|
|
69
|
+
stack do
|
|
70
|
+
title "Tool"
|
|
71
|
+
|
|
72
|
+
flow do
|
|
73
|
+
button "Cancel"
|
|
74
|
+
button "Continue"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`title` is the main heading, `subtitle` introduces a section, and `para`
|
|
81
|
+
represents regular prose:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
stack do
|
|
85
|
+
subtitle "Result"
|
|
86
|
+
para "The pressure is ", strong("high"),
|
|
87
|
+
" and requires ", em("review"), "."
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`para` renders as a paragraph and can combine strings with `strong` for
|
|
92
|
+
important content and `em` for emphasis.
|
|
93
|
+
|
|
94
|
+
`image` displays an image from a path or URL. Alternative text is optional, but
|
|
95
|
+
should describe the image whenever it carries meaningful content:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
image "red-sandals.jpg", alt: "A pair of red sandals"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`link` creates a regular external link. Navigation is handled entirely by the
|
|
102
|
+
browser and does not run a server-side action:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
link "Documentation", to: "https://example.com/docs"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Sandals currently accepts `http`, `https`, and `mailto` destinations. Internal
|
|
109
|
+
routes are reserved until a concrete application needs its own navigation.
|
|
110
|
+
|
|
111
|
+
## Fields and actions
|
|
112
|
+
|
|
113
|
+
`text_field` displays a labeled text input with an optional initial value. Its
|
|
114
|
+
block receives the new value whenever the field changes:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
text_field "Name", value: @name do |value|
|
|
118
|
+
@name = value
|
|
119
|
+
end
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Fields accept `error:` to display validation next to the control. Sandals marks
|
|
123
|
+
the field as invalid and associates the message using ARIA attributes:
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
text_field "Name", value: @name, error: @errors[:name] do |value|
|
|
127
|
+
@name = value
|
|
128
|
+
end
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`error` displays a general problem with an operation or form:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
error "Review the highlighted fields." unless @errors.empty?
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`notice` communicates a successful result or non-urgent information:
|
|
138
|
+
|
|
139
|
+
```ruby
|
|
140
|
+
notice "Profile saved."
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`button` runs its block using the Ruby state already updated by the fields:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
button "Greet" do
|
|
147
|
+
@message = "Hello, #{@name}"
|
|
148
|
+
end
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
While an action is pending, Sandals disables only the button that initiated it
|
|
152
|
+
and marks it with `aria-busy`. This prevents double clicks without interrupting
|
|
153
|
+
other fields.
|
|
154
|
+
|
|
155
|
+
If a field or button block raises an exception, Sandals keeps the last valid
|
|
156
|
+
view and displays the action name, error, file, and line. The next successful
|
|
157
|
+
event clears the message. Ruby state changed before the exception is not rolled
|
|
158
|
+
back automatically.
|
|
159
|
+
|
|
160
|
+
`number_field` works like `text_field`, but its block receives an `Integer`, a
|
|
161
|
+
`Float`, or `nil` when the field is empty:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
number_field "Quantity", value: @quantity do |value|
|
|
165
|
+
@quantity = value
|
|
166
|
+
end
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`text_area` edits multiline text and passes the new string to its block:
|
|
170
|
+
|
|
171
|
+
```ruby
|
|
172
|
+
text_area "Notes", value: @notes do |value|
|
|
173
|
+
@notes = value
|
|
174
|
+
end
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
`checkbox` represents a boolean choice and passes `true` or `false`:
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
checkbox "I agree", checked: @accepted do |checked|
|
|
181
|
+
@accepted = checked
|
|
182
|
+
end
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
`select` chooses a string from a collection of options:
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
select "Color", options: %w[Red Blue], value: @color do |value|
|
|
189
|
+
@color = value
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
When the Ruby value differs from the visible label, `options` can be a
|
|
194
|
+
`value => label` hash. The block receives the original Ruby value:
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
select(
|
|
198
|
+
"Country",
|
|
199
|
+
options: { mx: "Mexico", us: "United States" },
|
|
200
|
+
value: @country
|
|
201
|
+
) do |country|
|
|
202
|
+
@country = country
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`radio` provides the same key/value choice as a visible group:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
radio(
|
|
210
|
+
"Color",
|
|
211
|
+
options: { red: "Red", blue: "Blue" },
|
|
212
|
+
value: @color
|
|
213
|
+
) do |color|
|
|
214
|
+
@color = color
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Binding fields to models
|
|
219
|
+
|
|
220
|
+
A field can bind directly to the getter and setter of a Ruby object:
|
|
221
|
+
|
|
222
|
+
```ruby
|
|
223
|
+
text_field "Name", model: @profile, bind: :name
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
Sandals reads `@profile.name`, writes through `@profile.name=`, and, when the
|
|
227
|
+
object responds to `errors`, displays `@profile.errors[:name]`. `bind:` cannot
|
|
228
|
+
be combined with `value:` or an action block.
|
|
229
|
+
|
|
230
|
+
Binding is the recommended convention when state belongs to a model:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
text_field "Name", model: @profile, bind: :name
|
|
234
|
+
number_field "Age", model: @profile, bind: :age
|
|
235
|
+
checkbox "Newsletter", model: @profile, bind: :newsletter
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Inline blocks remain useful when a change requires a transformation or an
|
|
239
|
+
explicit effect, or when the state is so small that creating a model would not
|
|
240
|
+
improve readability:
|
|
241
|
+
|
|
242
|
+
```ruby
|
|
243
|
+
select "Currency", options: %w[MXN USD], value: @currency do |currency|
|
|
244
|
+
@currency = currency
|
|
245
|
+
@quote = Quotes.fetch(currency)
|
|
246
|
+
end
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
The two profile form versions and the margin calculator can be run separately:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
sandals run app
|
|
253
|
+
sandals run app_with_bindings
|
|
254
|
+
sandals run app_margin_calculator
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
## Forms
|
|
258
|
+
|
|
259
|
+
`form` allows an action to run when the user presses Enter through a `submit`.
|
|
260
|
+
It does not collect and resend field values: fields have already synchronized
|
|
261
|
+
their values with Ruby state.
|
|
262
|
+
|
|
263
|
+
```ruby
|
|
264
|
+
form do
|
|
265
|
+
text_field "New task", model: @draft, bind: :title
|
|
266
|
+
|
|
267
|
+
submit "Add" do
|
|
268
|
+
@list.add(@draft.title)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A `button` remains an independent action. `submit` can only be declared inside
|
|
274
|
+
a `form`.
|
|
275
|
+
|
|
276
|
+
## Uploaded files
|
|
277
|
+
|
|
278
|
+
`file_field` lets a user select a file without giving the application an
|
|
279
|
+
arbitrary system path:
|
|
280
|
+
|
|
281
|
+
```ruby
|
|
282
|
+
file_field "CSV file" do |file|
|
|
283
|
+
rows = CSV.parse(file.read, headers: true)
|
|
284
|
+
end
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The block receives a `Sandals::UploadedFile`:
|
|
288
|
+
|
|
289
|
+
```ruby
|
|
290
|
+
file.name
|
|
291
|
+
file.content_type
|
|
292
|
+
file.size
|
|
293
|
+
file.read
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The web host receives the file as multipart data with a 10 MB limit. The
|
|
297
|
+
runtime does not save it permanently. Tests can attach a fixture through the
|
|
298
|
+
semantic DSL:
|
|
299
|
+
|
|
300
|
+
```ruby
|
|
301
|
+
session.attach "CSV file", file: "test/fixtures/people.csv"
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
The complete application is available in `app_csv_reader.rb`.
|
|
305
|
+
|
|
306
|
+
## Tables
|
|
307
|
+
|
|
308
|
+
`table` displays collections using explicit headers and rows:
|
|
309
|
+
|
|
310
|
+
```ruby
|
|
311
|
+
table(
|
|
312
|
+
headers: ["Name", "City"],
|
|
313
|
+
rows: [
|
|
314
|
+
["Ada", "London"],
|
|
315
|
+
["Matz", "Tokyo"]
|
|
316
|
+
]
|
|
317
|
+
)
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Cells can use `strong` and `em`, but cannot contain interactive controls. Every
|
|
321
|
+
row must have the same number of cells as the header. `table` does not replace
|
|
322
|
+
a general `each`; it is for genuinely tabular data.
|
|
323
|
+
|
|
324
|
+
## Semantic tests
|
|
325
|
+
|
|
326
|
+
`Sandals.test` runs the application without a browser and finds controls by
|
|
327
|
+
their visible text rather than internal IDs:
|
|
328
|
+
|
|
329
|
+
```ruby
|
|
330
|
+
session = Sandals.test(application)
|
|
331
|
+
cost = session.field("Cost")
|
|
332
|
+
price = session.field("Price")
|
|
333
|
+
|
|
334
|
+
cost.fill 60
|
|
335
|
+
price.fill 100
|
|
336
|
+
|
|
337
|
+
assert session.text?("Margin: 40.0%")
|
|
338
|
+
|
|
339
|
+
price.fill 50
|
|
340
|
+
|
|
341
|
+
refute session.text?("Margin:")
|
|
342
|
+
assert_equal "Price must be greater than cost", session.field_error(price)
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
Other controls are operated using their visible labels and options:
|
|
346
|
+
|
|
347
|
+
```ruby
|
|
348
|
+
session.check "Newsletter"
|
|
349
|
+
session.uncheck "Newsletter"
|
|
350
|
+
session.select "Colombia", from: "Country"
|
|
351
|
+
session.choose "Phone", from: "Contact"
|
|
352
|
+
session.click "Save"
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The same operations are available through a field reference:
|
|
356
|
+
|
|
357
|
+
```ruby
|
|
358
|
+
session.field("Newsletter").check
|
|
359
|
+
session.field("Country").select("Colombia")
|
|
360
|
+
session.field("Contact").choose("Phone")
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
General messages can be distinguished from other visible text:
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
assert session.notice?("Saved")
|
|
367
|
+
assert session.error?("Could not save")
|
|
368
|
+
|
|
369
|
+
session.assert_notice "Saved"
|
|
370
|
+
session.refute_notice "Missing notice"
|
|
371
|
+
session.assert_error "Could not save"
|
|
372
|
+
session.refute_error "Missing error"
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
References such as `price` are semantic locators. They find the field again in
|
|
376
|
+
the current snapshot after every render instead of retaining stale objects
|
|
377
|
+
from a previous view.
|
|
378
|
+
|
|
379
|
+
## Sessions
|
|
380
|
+
|
|
381
|
+
Each browser gets an independent application instance. One person's interface
|
|
382
|
+
state is not shared with anyone else:
|
|
383
|
+
|
|
384
|
+
```ruby
|
|
385
|
+
definition = Sandals.app do
|
|
386
|
+
@name = ""
|
|
387
|
+
|
|
388
|
+
view do
|
|
389
|
+
text_field "Name", value: @name do |value|
|
|
390
|
+
@name = value
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
ana = definition.new_session
|
|
396
|
+
beto = definition.new_session
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
`ana` and `beto` run the same definition but keep separate variables and
|
|
400
|
+
snapshots. Data that must be shared between people should live in an external
|
|
401
|
+
model or repository, not in application instance variables.
|
|
402
|
+
|
|
403
|
+
## Persistence with external ORMs
|
|
404
|
+
|
|
405
|
+
Sandals does not include or wrap an ORM. Sequel and Active Record objects can
|
|
406
|
+
use `model:` and `bind:` through the same Ruby interface as any other model.
|
|
407
|
+
|
|
408
|
+
The repository includes two equivalent implementations of a persistent task
|
|
409
|
+
list:
|
|
410
|
+
|
|
411
|
+
- `examples/todos_sequel/app.rb`
|
|
412
|
+
- `examples/todos_active_record/app.rb`
|
|
413
|
+
|
|
414
|
+
Install the development dependencies and run either example:
|
|
415
|
+
|
|
416
|
+
```sh
|
|
417
|
+
bundle install
|
|
418
|
+
bundle exec bin/sandals run examples/todos_sequel/app.rb
|
|
419
|
+
bundle exec bin/sandals run examples/todos_active_record/app.rb
|
|
420
|
+
```
|
|
421
|
+
|
|
422
|
+
Each example configures SQLite, creates its table, and performs persistence
|
|
423
|
+
operations explicitly:
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
@draft.save
|
|
427
|
+
todo.update(completed:)
|
|
428
|
+
todo.destroy
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The SQLite file uses the example directory by default. To choose another
|
|
432
|
+
location:
|
|
433
|
+
|
|
434
|
+
```sh
|
|
435
|
+
SANDALS_TODOS_DATABASE=/path/to/todos.sqlite3 \
|
|
436
|
+
bundle exec bin/sandals run examples/todos_sequel/app.rb
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
This keeps a clear boundary: Sandals manages sessions and views; the
|
|
440
|
+
application's ORM manages records, validations, and the database.
|
|
441
|
+
|
|
442
|
+
Applications that query shared data can request periodic reevaluation:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
Sandals.app do
|
|
446
|
+
refresh every: 5
|
|
447
|
+
|
|
448
|
+
view do
|
|
449
|
+
Todo.order(:id).each do |todo|
|
|
450
|
+
# ...
|
|
451
|
+
end
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
The interval is expressed in seconds and must be at least one. Sandals
|
|
457
|
+
serializes refreshes with pending events, pauses them while the tab is hidden,
|
|
458
|
+
and refreshes when the tab becomes visible again. This feature is optional and
|
|
459
|
+
does not synchronize temporary state between sessions.
|
|
460
|
+
|
|
461
|
+
## Development reload
|
|
462
|
+
|
|
463
|
+
`sandals run` watches the application's main file. When the file changes and
|
|
464
|
+
can be loaded successfully, the browser reloads and starts a new session:
|
|
465
|
+
|
|
466
|
+
```sh
|
|
467
|
+
bundle exec bin/sandals run app.rb
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Temporary state and snapshots are reset; SQLite and other external storage
|
|
471
|
+
remain intact.
|
|
472
|
+
|
|
473
|
+
If the new version has a syntax or load error, Sandals keeps the last valid
|
|
474
|
+
definition and displays the error with its location. It continues watching the
|
|
475
|
+
file and recovers automatically when the error is fixed.
|
|
476
|
+
|
|
477
|
+
The current version watches only the file passed to `sandals run`, not files
|
|
478
|
+
loaded indirectly through `require`.
|
|
479
|
+
|
|
480
|
+
## Mental model
|
|
481
|
+
|
|
482
|
+
```text
|
|
483
|
+
Ruby state
|
|
484
|
+
↓
|
|
485
|
+
view
|
|
486
|
+
↓
|
|
487
|
+
Interface
|
|
488
|
+
↓
|
|
489
|
+
User action
|
|
490
|
+
↓
|
|
491
|
+
Ruby changes the state
|
|
492
|
+
↓
|
|
493
|
+
view is evaluated again
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
When the user presses a button, Sandals runs its block and produces the view
|
|
497
|
+
again. The interface is a direct representation of current state, without
|
|
498
|
+
manual DOM manipulation or an additional reactive system.
|
|
499
|
+
|
|
500
|
+
## Architecture
|
|
501
|
+
|
|
502
|
+
```text
|
|
503
|
+
app.rb
|
|
504
|
+
↓
|
|
505
|
+
Ruby
|
|
506
|
+
↓
|
|
507
|
+
Sandals runtime
|
|
508
|
+
↓
|
|
509
|
+
Local server
|
|
510
|
+
↓
|
|
511
|
+
HTML in the browser
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
The browser is the universal renderer. Application logic and state live in
|
|
515
|
+
Ruby.
|
|
516
|
+
|
|
517
|
+
Each evaluation of `view` produces a `Sandals::View` object independent from
|
|
518
|
+
the renderer:
|
|
519
|
+
|
|
520
|
+
```text
|
|
521
|
+
Application
|
|
522
|
+
↓ evaluates the block
|
|
523
|
+
Sandals::View
|
|
524
|
+
↓ contains elements
|
|
525
|
+
HTMLRenderer
|
|
526
|
+
↓
|
|
527
|
+
HTML
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
The view can be inspected directly:
|
|
531
|
+
|
|
532
|
+
```ruby
|
|
533
|
+
view = app.render_view
|
|
534
|
+
title = view.children.first
|
|
535
|
+
|
|
536
|
+
title.class
|
|
537
|
+
# Sandals::Title
|
|
538
|
+
|
|
539
|
+
title.content
|
|
540
|
+
# "Hello from Sandals"
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
This makes it possible to test and explain an application's structure without
|
|
544
|
+
depending on generated HTML. The block still runs in the application context,
|
|
545
|
+
so its Ruby state belongs to the application.
|
|
546
|
+
|
|
547
|
+
In the future, the same application could have different hosts:
|
|
548
|
+
|
|
549
|
+
```text
|
|
550
|
+
sandals run app.rb → local browser
|
|
551
|
+
sandals desktop app.rb → desktop window
|
|
552
|
+
sandals serve app.rb → accessible server
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Only the first mode is part of the current experiment.
|
|
556
|
+
|
|
557
|
+
## Design goals
|
|
558
|
+
|
|
559
|
+
Sandals favors:
|
|
560
|
+
|
|
561
|
+
- useful applications that are tens or hundreds of lines long;
|
|
562
|
+
- code that reads from top to bottom;
|
|
563
|
+
- state and effects that are easy to locate;
|
|
564
|
+
- behavior close to its trigger;
|
|
565
|
+
- a single Ruby source file as a starting point;
|
|
566
|
+
- fast execution and feedback;
|
|
567
|
+
- code that AI can generate, explain, and repair;
|
|
568
|
+
- tests based on what a person sees and does.
|
|
569
|
+
|
|
570
|
+
After reading an application, a Ruby developer should be able to answer:
|
|
571
|
+
|
|
572
|
+
1. What state does this application have?
|
|
573
|
+
2. What does it display?
|
|
574
|
+
3. What can the user do?
|
|
575
|
+
4. What does each action change?
|
|
576
|
+
5. Does it use files, the network, or storage?
|
|
577
|
+
|
|
578
|
+
The current version is not intended to provide:
|
|
579
|
+
|
|
580
|
+
- complete Shoes compatibility;
|
|
581
|
+
- Ruby inside WebAssembly;
|
|
582
|
+
- static deployable applications;
|
|
583
|
+
- packaged desktop applications;
|
|
584
|
+
- authentication;
|
|
585
|
+
- remote storage;
|
|
586
|
+
- integrated AI;
|
|
587
|
+
- a large component or widget catalog;
|
|
588
|
+
- canvas graphics or animation;
|
|
589
|
+
- operation as a public multi-instance server.
|
|
590
|
+
|
|
591
|
+
These capabilities can come later if real applications demonstrate a need for
|
|
592
|
+
them.
|
|
593
|
+
|
|
594
|
+
## Guiding principle
|
|
595
|
+
|
|
596
|
+
> Complexity should live in the runtime so the application can remain small,
|
|
597
|
+
> but never at the cost of hiding what the application does.
|
|
598
|
+
|
|
599
|
+
Sandals succeeds when running a tool is simple and reading it feels more like
|
|
600
|
+
understanding a small Ruby program than studying a framework.
|
|
601
|
+
|
|
602
|
+
## License
|
|
603
|
+
|
|
604
|
+
Sandals is available under the MIT License. See [`LICENSE`](LICENSE).
|
data/bin/sandals
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Sandals
|
|
4
|
+
class ActionError < StandardError
|
|
5
|
+
attr_reader :control, :original_error
|
|
6
|
+
|
|
7
|
+
def initialize(control, original_error)
|
|
8
|
+
@control = control
|
|
9
|
+
@original_error = original_error
|
|
10
|
+
super(original_error.message)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def action_name
|
|
14
|
+
return control.content if control.respond_to?(:content)
|
|
15
|
+
|
|
16
|
+
control.label
|
|
17
|
+
end
|
|
18
|
+
|
|
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}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
module Actionable
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def execute_action(*arguments)
|
|
36
|
+
@action&.call(*arguments)
|
|
37
|
+
rescue StandardError => error
|
|
38
|
+
raise ActionError.new(self, error), cause: error
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|