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.
@@ -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: []