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
data/lib/sandals/view.rb
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Sandals
|
|
6
|
+
class View
|
|
7
|
+
UNSET = BindingResolver::UNSET
|
|
8
|
+
|
|
9
|
+
attr_reader :children
|
|
10
|
+
|
|
11
|
+
def initialize
|
|
12
|
+
@children = []
|
|
13
|
+
@containers = [self]
|
|
14
|
+
@next_control_id = 0
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def title(content)
|
|
18
|
+
add Title.new(content)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def subtitle(content)
|
|
22
|
+
add Subtitle.new(content)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def para(*parts)
|
|
26
|
+
add Para.new(parts)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def error(content)
|
|
30
|
+
add Error.new(content)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def notice(content)
|
|
34
|
+
add Notice.new(content)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def image(source, alt: "")
|
|
38
|
+
add Image.new(source, alt:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def link(content, to:)
|
|
42
|
+
add Link.new(content, to:)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def table(headers:, rows:)
|
|
46
|
+
add Table.new(headers:, rows:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def text_field(
|
|
50
|
+
label,
|
|
51
|
+
value: UNSET,
|
|
52
|
+
error: UNSET,
|
|
53
|
+
model: UNSET,
|
|
54
|
+
bind: UNSET,
|
|
55
|
+
&action
|
|
56
|
+
)
|
|
57
|
+
value, error, action = resolve_binding(
|
|
58
|
+
model:, bind:, value:, error:, action:, default: ""
|
|
59
|
+
)
|
|
60
|
+
add TextField.new(control_id("text-field"), label, value:, error:, action:)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def number_field(
|
|
64
|
+
label,
|
|
65
|
+
value: UNSET,
|
|
66
|
+
error: UNSET,
|
|
67
|
+
model: UNSET,
|
|
68
|
+
bind: UNSET,
|
|
69
|
+
&action
|
|
70
|
+
)
|
|
71
|
+
value, error, action = resolve_binding(
|
|
72
|
+
model:, bind:, value:, error:, action:, default: nil
|
|
73
|
+
)
|
|
74
|
+
add NumberField.new(control_id("number-field"), label, value:, error:, action:)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def text_area(
|
|
78
|
+
label,
|
|
79
|
+
value: UNSET,
|
|
80
|
+
error: UNSET,
|
|
81
|
+
model: UNSET,
|
|
82
|
+
bind: UNSET,
|
|
83
|
+
&action
|
|
84
|
+
)
|
|
85
|
+
value, error, action = resolve_binding(
|
|
86
|
+
model:, bind:, value:, error:, action:, default: ""
|
|
87
|
+
)
|
|
88
|
+
add TextArea.new(control_id("text-area"), label, value:, error:, action:)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def checkbox(
|
|
92
|
+
label,
|
|
93
|
+
checked: UNSET,
|
|
94
|
+
error: UNSET,
|
|
95
|
+
model: UNSET,
|
|
96
|
+
bind: UNSET,
|
|
97
|
+
&action
|
|
98
|
+
)
|
|
99
|
+
checked, error, action = resolve_binding(
|
|
100
|
+
model:, bind:, value: checked, error:, action:, default: false
|
|
101
|
+
)
|
|
102
|
+
add Checkbox.new(control_id("checkbox"), label, checked:, error:, action:)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def select(
|
|
106
|
+
label,
|
|
107
|
+
options:,
|
|
108
|
+
value: UNSET,
|
|
109
|
+
error: UNSET,
|
|
110
|
+
model: UNSET,
|
|
111
|
+
bind: UNSET,
|
|
112
|
+
&action
|
|
113
|
+
)
|
|
114
|
+
value, error, action = resolve_binding(
|
|
115
|
+
model:, bind:, value:, error:, action:, default: nil
|
|
116
|
+
)
|
|
117
|
+
add Select.new(control_id("select"), label, options:, value:, error:, action:)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def radio(
|
|
121
|
+
label,
|
|
122
|
+
options:,
|
|
123
|
+
value: UNSET,
|
|
124
|
+
error: UNSET,
|
|
125
|
+
model: UNSET,
|
|
126
|
+
bind: UNSET,
|
|
127
|
+
&action
|
|
128
|
+
)
|
|
129
|
+
value, error, action = resolve_binding(
|
|
130
|
+
model:, bind:, value:, error:, action:, default: nil
|
|
131
|
+
)
|
|
132
|
+
add Radio.new(control_id("radio"), label, options:, value:, error:, action:)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def file_field(label, error: nil, &action)
|
|
136
|
+
add FileField.new(control_id("file-field"), label, error:, action:)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def button(content, &action)
|
|
140
|
+
add Button.new(control_id("button"), content, action:)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def submit(content, &action)
|
|
144
|
+
unless @containers.any? { |container| container.is_a?(Form) }
|
|
145
|
+
raise ArgumentError, "submit must be inside a form"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
add Submit.new(control_id("submit"), content, action:)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def strong(content)
|
|
152
|
+
Strong.new(content)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def em(content)
|
|
156
|
+
Emphasis.new(content)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def stack(&block)
|
|
160
|
+
container Stack.new, &block
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def flow(&block)
|
|
164
|
+
container Flow.new, &block
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def form(&block)
|
|
168
|
+
if @containers.any? { |container| container.is_a?(Form) }
|
|
169
|
+
raise ArgumentError, "forms cannot be nested"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
container Form.new, &block
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
def add(element)
|
|
178
|
+
@containers.last.children << element
|
|
179
|
+
element
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def container(element)
|
|
183
|
+
add element
|
|
184
|
+
@containers << element
|
|
185
|
+
yield
|
|
186
|
+
element
|
|
187
|
+
ensure
|
|
188
|
+
@containers.pop
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def control_id(prefix)
|
|
192
|
+
id = "#{prefix}-#{@next_control_id}"
|
|
193
|
+
@next_control_id += 1
|
|
194
|
+
id
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def resolve_binding(model:, bind:, value:, error:, action:, default:)
|
|
198
|
+
BindingResolver.resolve(
|
|
199
|
+
model:, bind:, value:, error:, action:, default:
|
|
200
|
+
).deconstruct
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
class Container
|
|
205
|
+
attr_reader :children
|
|
206
|
+
|
|
207
|
+
def initialize
|
|
208
|
+
@children = []
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
class Stack < Container; end
|
|
213
|
+
class Flow < Container; end
|
|
214
|
+
class Form < Container; end
|
|
215
|
+
|
|
216
|
+
class Title
|
|
217
|
+
attr_reader :content
|
|
218
|
+
|
|
219
|
+
def initialize(content)
|
|
220
|
+
@content = content.to_s
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
class Subtitle
|
|
225
|
+
attr_reader :content
|
|
226
|
+
|
|
227
|
+
def initialize(content)
|
|
228
|
+
@content = content.to_s
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
class Para
|
|
233
|
+
attr_reader :parts
|
|
234
|
+
|
|
235
|
+
def initialize(parts)
|
|
236
|
+
@parts = parts
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
class Error
|
|
241
|
+
attr_reader :content
|
|
242
|
+
|
|
243
|
+
def initialize(content)
|
|
244
|
+
@content = content.to_s
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
class Notice
|
|
249
|
+
attr_reader :content
|
|
250
|
+
|
|
251
|
+
def initialize(content)
|
|
252
|
+
@content = content.to_s
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
class Image
|
|
257
|
+
attr_reader :source, :alt
|
|
258
|
+
|
|
259
|
+
def initialize(source, alt:)
|
|
260
|
+
@source = source.to_s
|
|
261
|
+
@alt = alt.to_s
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
class Link
|
|
266
|
+
ALLOWED_SCHEMES = %w[http https mailto].freeze
|
|
267
|
+
|
|
268
|
+
attr_reader :content, :destination
|
|
269
|
+
|
|
270
|
+
def initialize(content, to:)
|
|
271
|
+
@content = content.to_s
|
|
272
|
+
@destination = to.to_s
|
|
273
|
+
validate_destination
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
private
|
|
277
|
+
|
|
278
|
+
def validate_destination
|
|
279
|
+
uri = URI.parse(destination)
|
|
280
|
+
valid_http = uri.is_a?(URI::HTTP) && uri.host
|
|
281
|
+
valid_mail = uri.scheme == "mailto" && !uri.opaque.to_s.empty?
|
|
282
|
+
return if ALLOWED_SCHEMES.include?(uri.scheme) && (valid_http || valid_mail)
|
|
283
|
+
|
|
284
|
+
raise ArgumentError,
|
|
285
|
+
"link destination must be an external HTTP, HTTPS, or mailto URL"
|
|
286
|
+
rescue URI::InvalidURIError
|
|
287
|
+
raise ArgumentError,
|
|
288
|
+
"link destination must be an external HTTP, HTTPS, or mailto URL"
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
module FieldValidation
|
|
293
|
+
attr_reader :error
|
|
294
|
+
|
|
295
|
+
def invalid?
|
|
296
|
+
!error.nil?
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def error_id
|
|
300
|
+
"#{id}-error"
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
private
|
|
304
|
+
|
|
305
|
+
def initialize_error(error)
|
|
306
|
+
@error =
|
|
307
|
+
if error.is_a?(Array)
|
|
308
|
+
error.map(&:to_s).join(", ")
|
|
309
|
+
else
|
|
310
|
+
error&.to_s
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
class TextField
|
|
316
|
+
include FieldValidation
|
|
317
|
+
include Actionable
|
|
318
|
+
|
|
319
|
+
attr_reader :id, :label, :value
|
|
320
|
+
|
|
321
|
+
def initialize(id, label, value:, action:, error: nil)
|
|
322
|
+
@id = id
|
|
323
|
+
@label = label.to_s
|
|
324
|
+
@value = value.to_s
|
|
325
|
+
initialize_error(error)
|
|
326
|
+
@action = action
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def change(value)
|
|
330
|
+
execute_action(value)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
class NumberField
|
|
335
|
+
include FieldValidation
|
|
336
|
+
include Actionable
|
|
337
|
+
|
|
338
|
+
attr_reader :id, :label, :value
|
|
339
|
+
|
|
340
|
+
def initialize(id, label, value:, action:, error: nil)
|
|
341
|
+
@id = id
|
|
342
|
+
@label = label.to_s
|
|
343
|
+
@value = value
|
|
344
|
+
initialize_error(error)
|
|
345
|
+
@action = action
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def change(value)
|
|
349
|
+
execute_action(number(value))
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
def number(value)
|
|
355
|
+
return nil if value.nil? || value.empty?
|
|
356
|
+
|
|
357
|
+
Integer(value, exception: false) ||
|
|
358
|
+
Float(value, exception: false) ||
|
|
359
|
+
raise(ArgumentError, "invalid number: #{value}")
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
class TextArea
|
|
364
|
+
include FieldValidation
|
|
365
|
+
include Actionable
|
|
366
|
+
|
|
367
|
+
attr_reader :id, :label, :value
|
|
368
|
+
|
|
369
|
+
def initialize(id, label, value:, action:, error: nil)
|
|
370
|
+
@id = id
|
|
371
|
+
@label = label.to_s
|
|
372
|
+
@value = value.to_s
|
|
373
|
+
initialize_error(error)
|
|
374
|
+
@action = action
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def change(value)
|
|
378
|
+
execute_action(value)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
class Checkbox
|
|
383
|
+
include FieldValidation
|
|
384
|
+
include Actionable
|
|
385
|
+
|
|
386
|
+
attr_reader :id, :label
|
|
387
|
+
|
|
388
|
+
def initialize(id, label, checked:, action:, error: nil)
|
|
389
|
+
@id = id
|
|
390
|
+
@label = label.to_s
|
|
391
|
+
@checked = checked
|
|
392
|
+
initialize_error(error)
|
|
393
|
+
@action = action
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
def checked?
|
|
397
|
+
@checked
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def change(checked)
|
|
401
|
+
execute_action(checked)
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
class Select
|
|
406
|
+
include FieldValidation
|
|
407
|
+
include Actionable
|
|
408
|
+
|
|
409
|
+
Option = Data.define(:value, :label)
|
|
410
|
+
|
|
411
|
+
attr_reader :id, :label, :options, :value
|
|
412
|
+
|
|
413
|
+
def initialize(id, label, options:, value:, action:, error: nil)
|
|
414
|
+
@id = id
|
|
415
|
+
@label = label.to_s
|
|
416
|
+
@options = normalize(options)
|
|
417
|
+
@value = value
|
|
418
|
+
initialize_error(error)
|
|
419
|
+
@action = action
|
|
420
|
+
validate_unique_values
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
def change(value)
|
|
424
|
+
option = @options.find { |candidate| candidate.value.to_s == value }
|
|
425
|
+
raise ArgumentError, "unknown select value: #{value}" unless option
|
|
426
|
+
|
|
427
|
+
execute_action(option.value)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
def selected?(option)
|
|
431
|
+
option.value == value
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
def normalize(options)
|
|
437
|
+
if options.is_a?(Hash)
|
|
438
|
+
options.map do |value, label|
|
|
439
|
+
Option.new(value, label.to_s)
|
|
440
|
+
end
|
|
441
|
+
else
|
|
442
|
+
options.map do |value|
|
|
443
|
+
Option.new(value, value.to_s)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def validate_unique_values
|
|
449
|
+
values = @options.map { |option| option.value.to_s }
|
|
450
|
+
return if values.uniq.length == values.length
|
|
451
|
+
|
|
452
|
+
raise ArgumentError, "select values must be unique after serialization"
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
class Radio < Select; end
|
|
457
|
+
|
|
458
|
+
class FileField
|
|
459
|
+
include FieldValidation
|
|
460
|
+
include Actionable
|
|
461
|
+
|
|
462
|
+
attr_reader :id, :label
|
|
463
|
+
|
|
464
|
+
def initialize(id, label, action:, error: nil)
|
|
465
|
+
@id = id
|
|
466
|
+
@label = label.to_s
|
|
467
|
+
initialize_error(error)
|
|
468
|
+
@action = action
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def change(file)
|
|
472
|
+
execute_action(file)
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
class Button
|
|
477
|
+
include Actionable
|
|
478
|
+
|
|
479
|
+
attr_reader :id, :content
|
|
480
|
+
|
|
481
|
+
def initialize(id, content, action:)
|
|
482
|
+
@id = id
|
|
483
|
+
@content = content.to_s
|
|
484
|
+
@action = action
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def activate
|
|
488
|
+
execute_action
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
class Submit < Button
|
|
493
|
+
def submit
|
|
494
|
+
activate
|
|
495
|
+
end
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
class Strong
|
|
499
|
+
attr_reader :content
|
|
500
|
+
|
|
501
|
+
def initialize(content)
|
|
502
|
+
@content = content.to_s
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
class Emphasis
|
|
507
|
+
attr_reader :content
|
|
508
|
+
|
|
509
|
+
def initialize(content)
|
|
510
|
+
@content = content.to_s
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
class Table
|
|
515
|
+
FORBIDDEN_CELL_TYPES = [
|
|
516
|
+
Container,
|
|
517
|
+
TextField,
|
|
518
|
+
NumberField,
|
|
519
|
+
TextArea,
|
|
520
|
+
Checkbox,
|
|
521
|
+
Select,
|
|
522
|
+
FileField,
|
|
523
|
+
Button,
|
|
524
|
+
Title,
|
|
525
|
+
Subtitle,
|
|
526
|
+
Para,
|
|
527
|
+
Error,
|
|
528
|
+
Notice,
|
|
529
|
+
Image,
|
|
530
|
+
Link
|
|
531
|
+
].freeze
|
|
532
|
+
|
|
533
|
+
attr_reader :headers, :rows
|
|
534
|
+
|
|
535
|
+
def initialize(headers:, rows:)
|
|
536
|
+
@headers = collection(headers, "table headers").map(&:to_s).freeze
|
|
537
|
+
@rows = collection(rows, "table rows").each_with_index.map do |row, index|
|
|
538
|
+
cells = collection(row, "table row #{index + 1}")
|
|
539
|
+
unless cells.length == @headers.length
|
|
540
|
+
raise ArgumentError,
|
|
541
|
+
"table row #{index + 1} has #{cells.length} cells, " \
|
|
542
|
+
"but #{@headers.length} headers were provided"
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
cells.map { |cell| normalize_cell(cell) }.freeze
|
|
546
|
+
end.freeze
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
private
|
|
550
|
+
|
|
551
|
+
def collection(value, name)
|
|
552
|
+
return value.to_a if value.respond_to?(:to_a) && !value.is_a?(String)
|
|
553
|
+
|
|
554
|
+
raise ArgumentError, "#{name} must be a collection"
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def normalize_cell(cell)
|
|
558
|
+
if cell.is_a?(Table) ||
|
|
559
|
+
FORBIDDEN_CELL_TYPES.any? { |type| cell.is_a?(type) }
|
|
560
|
+
raise ArgumentError, "table cells cannot contain #{cell.class}"
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
return cell if cell.is_a?(Strong) || cell.is_a?(Emphasis)
|
|
564
|
+
|
|
565
|
+
cell.to_s
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
data/lib/sandals.rb
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sandals/version"
|
|
4
|
+
require_relative "sandals/binding_resolver"
|
|
5
|
+
require_relative "sandals/action_error"
|
|
6
|
+
require_relative "sandals/uploaded_file"
|
|
7
|
+
require_relative "sandals/view"
|
|
8
|
+
require_relative "sandals/html_renderer"
|
|
9
|
+
require_relative "sandals/snapshot_history"
|
|
10
|
+
require_relative "sandals/event_dispatcher"
|
|
11
|
+
require_relative "sandals/application"
|
|
12
|
+
require_relative "sandals/test_session"
|
|
13
|
+
require_relative "sandals/code_reloader"
|
|
14
|
+
require_relative "sandals/server"
|
|
15
|
+
|
|
16
|
+
module Sandals
|
|
17
|
+
class << self
|
|
18
|
+
attr_reader :application
|
|
19
|
+
|
|
20
|
+
def app(title: "Sandals", &block)
|
|
21
|
+
@application = Application.new(title:, &block)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def load(path)
|
|
25
|
+
@application = nil
|
|
26
|
+
Kernel.load(File.expand_path(path))
|
|
27
|
+
application || raise(ArgumentError, "#{path} does not define Sandals.app")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test(application)
|
|
31
|
+
TestSession.new(application)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: sandals
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Benito Serna
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: webrick
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.9'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.9'
|
|
26
|
+
description: |-
|
|
27
|
+
Sandals is a small local runtime for building interactive applications in
|
|
28
|
+
ordinary Ruby without writing HTML or JavaScript.
|
|
29
|
+
executables:
|
|
30
|
+
- sandals
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- bin/sandals
|
|
37
|
+
- lib/sandals.rb
|
|
38
|
+
- lib/sandals/action_error.rb
|
|
39
|
+
- lib/sandals/application.rb
|
|
40
|
+
- lib/sandals/assets/TURBO-LICENSE
|
|
41
|
+
- lib/sandals/assets/turbo.es2017-umd.js
|
|
42
|
+
- lib/sandals/binding_resolver.rb
|
|
43
|
+
- lib/sandals/cli.rb
|
|
44
|
+
- lib/sandals/code_reloader.rb
|
|
45
|
+
- lib/sandals/event_dispatcher.rb
|
|
46
|
+
- lib/sandals/html_renderer.rb
|
|
47
|
+
- lib/sandals/server.rb
|
|
48
|
+
- lib/sandals/snapshot_history.rb
|
|
49
|
+
- lib/sandals/test_session.rb
|
|
50
|
+
- lib/sandals/uploaded_file.rb
|
|
51
|
+
- lib/sandals/version.rb
|
|
52
|
+
- lib/sandals/view.rb
|
|
53
|
+
licenses:
|
|
54
|
+
- MIT
|
|
55
|
+
metadata:
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.1'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.7.2
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: Build small interactive applications using only Ruby
|
|
74
|
+
test_files: []
|