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,686 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Sandals
6
+ class HTMLRenderer
7
+ TURBO_URL = "/__sandals/assets/turbo.js"
8
+ TURBO_PATH = File.expand_path("assets/turbo.es2017-umd.js", __dir__)
9
+
10
+ DEFAULT_STYLES = <<~CSS
11
+ :root {
12
+ color-scheme: light;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
14
+ background: #f7f7f5;
15
+ color: #20201e;
16
+ }
17
+
18
+ body {
19
+ margin: 0;
20
+ }
21
+
22
+ main {
23
+ box-sizing: border-box;
24
+ width: min(100%, 48rem);
25
+ margin: 0 auto;
26
+ padding: 3rem 1.5rem;
27
+ }
28
+
29
+ h1 {
30
+ margin: 0;
31
+ font-size: clamp(2rem, 7vw, 4rem);
32
+ letter-spacing: -0.04em;
33
+ }
34
+
35
+ p {
36
+ margin: 0;
37
+ line-height: 1.6;
38
+ }
39
+
40
+ .error,
41
+ .field-error {
42
+ color: #b42318;
43
+ }
44
+
45
+ .notice {
46
+ color: #067647;
47
+ }
48
+
49
+ .runtime-errors {
50
+ box-sizing: border-box;
51
+ width: min(100%, 48rem);
52
+ margin: 0 auto;
53
+ padding: 1rem 1.5rem 0;
54
+ }
55
+
56
+ .action-error {
57
+ display: grid;
58
+ gap: 0.375rem;
59
+ padding: 1rem;
60
+ border: 1px solid #fda29b;
61
+ border-radius: 0.375rem;
62
+ background: #fef3f2;
63
+ color: #b42318;
64
+ }
65
+
66
+ .action-error code {
67
+ overflow-wrap: anywhere;
68
+ }
69
+
70
+ .field-error {
71
+ font-size: 0.875rem;
72
+ }
73
+
74
+ img {
75
+ display: block;
76
+ max-width: 100%;
77
+ height: auto;
78
+ }
79
+
80
+ a {
81
+ color: #175cd3;
82
+ text-underline-offset: 0.15em;
83
+ }
84
+
85
+ .text-field,
86
+ .number-field,
87
+ .text-area,
88
+ .file-field,
89
+ .select {
90
+ display: grid;
91
+ gap: 0.375rem;
92
+ }
93
+
94
+ .text-field input,
95
+ .number-field input,
96
+ .text-area textarea,
97
+ .file-field input,
98
+ .select select {
99
+ box-sizing: border-box;
100
+ width: 100%;
101
+ padding: 0.625rem 0.75rem;
102
+ border: 1px solid #c8c8c3;
103
+ border-radius: 0.375rem;
104
+ background: white;
105
+ color: inherit;
106
+ font: inherit;
107
+ }
108
+
109
+ input[aria-invalid="true"],
110
+ textarea[aria-invalid="true"],
111
+ select[aria-invalid="true"] {
112
+ border-color: #b42318;
113
+ }
114
+
115
+ .text-area textarea {
116
+ min-height: 7rem;
117
+ resize: vertical;
118
+ }
119
+
120
+ .checkbox {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 0.5rem;
124
+ }
125
+
126
+ .checkbox input {
127
+ width: 1rem;
128
+ height: 1rem;
129
+ margin: 0;
130
+ }
131
+
132
+ .radio {
133
+ display: grid;
134
+ gap: 0.5rem;
135
+ margin: 0;
136
+ padding: 0;
137
+ border: 0;
138
+ }
139
+
140
+ .radio legend {
141
+ margin-bottom: 0.375rem;
142
+ }
143
+
144
+ .radio label {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 0.5rem;
148
+ }
149
+
150
+ .radio input {
151
+ margin: 0;
152
+ }
153
+
154
+ table {
155
+ width: 100%;
156
+ border-collapse: collapse;
157
+ background: white;
158
+ }
159
+
160
+ th,
161
+ td {
162
+ padding: 0.625rem 0.75rem;
163
+ border: 1px solid #deded9;
164
+ text-align: left;
165
+ vertical-align: top;
166
+ }
167
+
168
+ th {
169
+ background: #f0f0ed;
170
+ font-weight: 600;
171
+ }
172
+
173
+ button {
174
+ padding: 0.625rem 1rem;
175
+ border: 0;
176
+ border-radius: 0.375rem;
177
+ background: #20201e;
178
+ color: white;
179
+ font: inherit;
180
+ cursor: pointer;
181
+ }
182
+
183
+ button[aria-busy="true"] {
184
+ cursor: wait;
185
+ opacity: 0.7;
186
+ }
187
+
188
+ .stack {
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 1rem;
192
+ }
193
+
194
+ .flow {
195
+ display: flex;
196
+ flex-flow: row wrap;
197
+ align-items: center;
198
+ gap: 1rem;
199
+ }
200
+
201
+ .form {
202
+ display: flex;
203
+ flex-direction: column;
204
+ gap: 1rem;
205
+ }
206
+ CSS
207
+
208
+ def render(application, view, revision:, development_reload: false)
209
+ document(
210
+ application.title,
211
+ render_children(view),
212
+ revision:,
213
+ refresh_interval: application.refresh_interval,
214
+ development_reload:
215
+ )
216
+ end
217
+
218
+ def render_fragment(view)
219
+ render_children(view)
220
+ end
221
+
222
+ def render_action_error(error)
223
+ original = error.original_error
224
+ <<~HTML
225
+ <turbo-stream action="update" target="sandals-errors">
226
+ <template><section class="action-error" role="alert"><strong>Action failed for “#{escape(error.action_name)}”</strong><p>#{escape(original.class)}: #{escape(original.message)}</p><code>#{escape(error.location)}</code></section></template>
227
+ </turbo-stream>
228
+ HTML
229
+ end
230
+
231
+ def clear_action_error
232
+ <<~HTML
233
+ <turbo-stream action="update" target="sandals-errors">
234
+ <template></template>
235
+ </turbo-stream>
236
+ HTML
237
+ end
238
+
239
+ private
240
+
241
+ def render_children(view)
242
+ view.children.map { |element| render_element(element) }.join
243
+ end
244
+
245
+ def render_element(element)
246
+ case element
247
+ when String
248
+ escape(element)
249
+ when Title
250
+ "<h1>#{escape(element.content)}</h1>"
251
+ when Subtitle
252
+ "<h2>#{escape(element.content)}</h2>"
253
+ when Para
254
+ "<p>#{element.parts.map { |part| render_element(part) }.join}</p>"
255
+ when Error
256
+ %(<p class="error" role="alert">#{escape(element.content)}</p>)
257
+ when Notice
258
+ %(<p class="notice" role="status">#{escape(element.content)}</p>)
259
+ when Image
260
+ %(<img src="#{escape(element.source)}" alt="#{escape(element.alt)}">)
261
+ when Link
262
+ %(<a href="#{escape(element.destination)}">#{escape(element.content)}</a>)
263
+ when Table
264
+ table(element)
265
+ when TextField
266
+ %(<label class="text-field"><span>#{escape(element.label)}</span><input type="text" value="#{escape(element.value)}" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
267
+ when NumberField
268
+ %(<label class="number-field"><span>#{escape(element.label)}</span><input type="number" value="#{escape(element.value)}" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
269
+ when TextArea
270
+ %(<label class="text-area"><span>#{escape(element.label)}</span><textarea data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{escape(element.value)}</textarea>#{field_error(element)}</label>)
271
+ when FileField
272
+ %(<label class="file-field"><span>#{escape(element.label)}</span><input type="file" data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{field_error(element)}</label>)
273
+ when Checkbox
274
+ checked = element.checked? ? " checked" : ""
275
+ %(<label class="checkbox"><input type="checkbox"#{checked} data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}><span>#{escape(element.label)}</span>#{field_error(element)}</label>)
276
+ when Radio
277
+ options = element.options.map do |option|
278
+ checked = element.selected?(option) ? " checked" : ""
279
+ %(<label><input type="radio" name="#{escape(element.id)}" value="#{escape(option.value)}"#{checked} data-sandals-target="#{escape(element.id)}"><span>#{escape(option.label)}</span></label>)
280
+ end.join
281
+ %(<fieldset class="radio"#{validation_attributes(element)}><legend>#{escape(element.label)}</legend>#{options}#{field_error(element)}</fieldset>)
282
+ when Select
283
+ options = element.options.map do |option|
284
+ selected = element.selected?(option) ? " selected" : ""
285
+ %(<option value="#{escape(option.value)}"#{selected}>#{escape(option.label)}</option>)
286
+ end.join
287
+ %(<label class="select"><span>#{escape(element.label)}</span><select data-sandals-target="#{escape(element.id)}"#{validation_attributes(element)}>#{options}</select>#{field_error(element)}</label>)
288
+ when Submit
289
+ %(<button type="submit" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
290
+ when Button
291
+ %(<button type="button" data-sandals-target="#{escape(element.id)}">#{escape(element.content)}</button>)
292
+ when Strong
293
+ "<strong>#{escape(element.content)}</strong>"
294
+ when Emphasis
295
+ "<em>#{escape(element.content)}</em>"
296
+ when Stack
297
+ container("stack", element)
298
+ when Flow
299
+ container("flow", element)
300
+ when Form
301
+ form(element)
302
+ else
303
+ raise ArgumentError, "cannot render #{element.class}"
304
+ end
305
+ end
306
+
307
+ def container(class_name, element)
308
+ content = element.children.map { |child| render_element(child) }.join
309
+ %(<div class="#{class_name}">#{content}</div>)
310
+ end
311
+
312
+ def form(element)
313
+ content = element.children.map { |child| render_element(child) }.join
314
+ %(<form class="form">#{content}</form>)
315
+ end
316
+
317
+ def table(element)
318
+ headers = element.headers.map do |header|
319
+ %(<th scope="col">#{escape(header)}</th>)
320
+ end.join
321
+ rows = element.rows.map do |row|
322
+ cells = row.map { |cell| "<td>#{render_element(cell)}</td>" }.join
323
+ "<tr>#{cells}</tr>"
324
+ end.join
325
+ "<table><thead><tr>#{headers}</tr></thead><tbody>#{rows}</tbody></table>"
326
+ end
327
+
328
+ def validation_attributes(element)
329
+ return "" unless element.invalid?
330
+
331
+ %( aria-invalid="true" aria-describedby="#{escape(element.error_id)}")
332
+ end
333
+
334
+ def field_error(element)
335
+ return "" unless element.invalid?
336
+
337
+ %(<span id="#{escape(element.error_id)}" class="field-error" role="alert">#{escape(element.error)}</span>)
338
+ end
339
+
340
+ def document(
341
+ title,
342
+ content,
343
+ revision:,
344
+ refresh_interval:,
345
+ development_reload:
346
+ )
347
+ refresh_attribute =
348
+ if refresh_interval
349
+ %( data-sandals-refresh-interval="#{refresh_interval}")
350
+ else
351
+ ""
352
+ end
353
+ reload_attribute =
354
+ development_reload ? %( data-sandals-development-reload="true") : ""
355
+ <<~HTML
356
+ <!doctype html>
357
+ <html lang="es">
358
+ <head>
359
+ <meta charset="utf-8">
360
+ <meta name="viewport" content="width=device-width, initial-scale=1">
361
+ <title>#{escape(title)}</title>
362
+ <style>#{DEFAULT_STYLES}</style>
363
+ <script src="#{TURBO_URL}"></script>
364
+ <script>#{client_script}</script>
365
+ </head>
366
+ <body>
367
+ <div id="sandals-errors" class="runtime-errors" aria-live="assertive"></div>
368
+ <main id="sandals-view" data-sandals-revision="#{revision}"#{refresh_attribute}#{reload_attribute}>#{content}</main>
369
+ </body>
370
+ </html>
371
+ HTML
372
+ end
373
+
374
+ def client_script
375
+ <<~JAVASCRIPT
376
+ document.addEventListener("DOMContentLoaded", () => {
377
+ let active = false
378
+ let revision = Number(
379
+ document.querySelector("#sandals-view").dataset.sandalsRevision
380
+ )
381
+ const refreshInterval = Number(
382
+ document.querySelector("#sandals-view").dataset.sandalsRefreshInterval || 0
383
+ )
384
+ const developmentReload =
385
+ document.querySelector("#sandals-view").dataset.sandalsDevelopmentReload === "true"
386
+ let codeVersion
387
+ let checkingCode = false
388
+ let refreshTimer
389
+ const pending = []
390
+ const pendingButtons = new Map()
391
+
392
+ function markButtonPending(button) {
393
+ const target = button.dataset.sandalsTarget
394
+
395
+ pendingButtons.set(target, button)
396
+ button.dataset.sandalsPending = "true"
397
+ button.disabled = true
398
+ button.setAttribute("aria-busy", "true")
399
+ }
400
+
401
+ function releasePendingButtons(events) {
402
+ for (const event of events) {
403
+ if (!["click", "submit"].includes(event.event)) continue
404
+
405
+ const button = pendingButtons.get(event.target)
406
+ if (!button) continue
407
+
408
+ button.disabled = false
409
+ button.removeAttribute("aria-busy")
410
+ delete button.dataset.sandalsPending
411
+ pendingButtons.delete(event.target)
412
+ }
413
+ }
414
+
415
+ function showCodeError(error) {
416
+ const region = document.querySelector("#sandals-errors")
417
+ const section = document.createElement("section")
418
+ const title = document.createElement("strong")
419
+ const message = document.createElement("p")
420
+ const location = document.createElement("code")
421
+
422
+ section.className = "action-error"
423
+ section.setAttribute("role", "alert")
424
+ title.textContent = "Could not reload application"
425
+ message.textContent = `${error.class}: ${error.message}`
426
+ location.textContent = error.location
427
+ section.append(title, message, location)
428
+ region.replaceChildren(section)
429
+ }
430
+
431
+ async function checkForCodeChanges() {
432
+ if (!developmentReload || document.hidden || checkingCode) return
433
+
434
+ checkingCode = true
435
+ try {
436
+ const response = await fetch("/__sandals/reload", {
437
+ headers: { "Accept": "application/json" }
438
+ })
439
+ if (!response.ok) return
440
+
441
+ const result = await response.json()
442
+ if (codeVersion === undefined) {
443
+ codeVersion = result.version
444
+ } else if (!result.error && result.version !== codeVersion) {
445
+ window.location.reload()
446
+ return
447
+ }
448
+
449
+ if (result.error) showCodeError(result.error)
450
+ } finally {
451
+ checkingCode = false
452
+ }
453
+ }
454
+
455
+ function enqueue(message) {
456
+ scheduleRefresh()
457
+ const last = pending.at(-1)
458
+
459
+ if (
460
+ message.event === "change" &&
461
+ last?.revision === revision &&
462
+ last?.event === "change" &&
463
+ last?.target === message.target
464
+ ) {
465
+ last.value = message.value
466
+ } else {
467
+ pending.push({ revision, ...message })
468
+ }
469
+
470
+ drain()
471
+ }
472
+
473
+ function scheduleRefresh() {
474
+ clearTimeout(refreshTimer)
475
+ if (!refreshInterval || document.hidden) return
476
+
477
+ refreshTimer = setTimeout(refreshView, refreshInterval * 1000)
478
+ }
479
+
480
+ async function refreshView() {
481
+ clearTimeout(refreshTimer)
482
+ if (!refreshInterval || document.hidden) return
483
+ if (active || pending.length > 0) {
484
+ scheduleRefresh()
485
+ return
486
+ }
487
+
488
+ active = true
489
+ const refreshRevision = revision
490
+
491
+ try {
492
+ const response = await fetch("/events", {
493
+ method: "POST",
494
+ headers: {
495
+ "Accept": "text/vnd.turbo-stream.html",
496
+ "Content-Type": "application/json"
497
+ },
498
+ body: JSON.stringify({
499
+ revision: refreshRevision,
500
+ oldest_pending_revision: refreshRevision,
501
+ events: []
502
+ })
503
+ })
504
+
505
+ const body = await response.text()
506
+ const contentType = response.headers.get("Content-Type") || ""
507
+
508
+ if (contentType.includes("text/vnd.turbo-stream.html")) {
509
+ document.body.insertAdjacentHTML("beforeend", body)
510
+ if (response.ok) {
511
+ revision = Number(response.headers.get("X-Sandals-Revision"))
512
+ }
513
+ } else if (!response.ok) {
514
+ throw new Error(body)
515
+ }
516
+ } finally {
517
+ active = false
518
+ drain()
519
+ scheduleRefresh()
520
+ }
521
+ }
522
+
523
+ async function drain() {
524
+ if (active || pending.length === 0) return
525
+
526
+ active = true
527
+ const batchRevision = pending[0].revision
528
+ const events = []
529
+ let upload
530
+
531
+ if (pending[0].event === "upload") {
532
+ upload = pending.shift()
533
+ }
534
+
535
+ while (!upload && pending[0]?.revision === batchRevision) {
536
+ const { revision: _, ...event } = pending.shift()
537
+ events.push(event)
538
+ if (["click", "submit"].includes(event.event)) break
539
+ }
540
+
541
+ try {
542
+ let response
543
+
544
+ if (upload) {
545
+ const formData = new FormData()
546
+ formData.append("revision", batchRevision)
547
+ formData.append("oldest_pending_revision", batchRevision)
548
+ formData.append("target", upload.target)
549
+ formData.append("file", upload.file)
550
+ response = await fetch("/files", {
551
+ method: "POST",
552
+ headers: { "Accept": "text/vnd.turbo-stream.html" },
553
+ body: formData
554
+ })
555
+ } else {
556
+ response = await fetch("/events", {
557
+ method: "POST",
558
+ headers: {
559
+ "Accept": "text/vnd.turbo-stream.html",
560
+ "Content-Type": "application/json"
561
+ },
562
+ body: JSON.stringify({
563
+ revision: batchRevision,
564
+ oldest_pending_revision: batchRevision,
565
+ events
566
+ })
567
+ })
568
+ }
569
+
570
+ const body = await response.text()
571
+ const contentType = response.headers.get("Content-Type") || ""
572
+
573
+ if (contentType.includes("text/vnd.turbo-stream.html")) {
574
+ document.body.insertAdjacentHTML("beforeend", body)
575
+ if (response.ok) {
576
+ revision = Number(response.headers.get("X-Sandals-Revision"))
577
+ }
578
+ } else if (!response.ok) {
579
+ throw new Error(body)
580
+ }
581
+ } finally {
582
+ releasePendingButtons(events)
583
+ active = false
584
+ drain()
585
+ scheduleRefresh()
586
+ }
587
+ }
588
+
589
+ document.addEventListener("input", (event) => {
590
+ const field = event.target.closest(
591
+ "input[data-sandals-target], textarea[data-sandals-target], " +
592
+ "select[data-sandals-target]"
593
+ )
594
+ if (!field) return
595
+
596
+ if (field.type === "file") {
597
+ const file = field.files[0]
598
+ if (!file) return
599
+
600
+ enqueue({
601
+ target: field.dataset.sandalsTarget,
602
+ event: "upload",
603
+ file
604
+ })
605
+ return
606
+ }
607
+
608
+ enqueue({
609
+ target: field.dataset.sandalsTarget,
610
+ event: "change",
611
+ value: field.type === "checkbox" ? field.checked : field.value
612
+ })
613
+ })
614
+
615
+ document.addEventListener("click", (event) => {
616
+ const button = event.target.closest("button[data-sandals-target]")
617
+ if (!button) return
618
+ if (button.type === "submit") return
619
+ if (button.disabled || button.dataset.sandalsPending) return
620
+
621
+ markButtonPending(button)
622
+ enqueue({
623
+ target: button.dataset.sandalsTarget,
624
+ event: "click"
625
+ })
626
+ })
627
+
628
+ document.addEventListener("keydown", (event) => {
629
+ if (event.key !== "Enter") return
630
+ if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return
631
+
632
+ const field = event.target.closest(
633
+ 'form input:not([type="checkbox"]):not([type="radio"])'
634
+ )
635
+ if (!field) return
636
+
637
+ const form = field.closest("form")
638
+ const button =
639
+ form.querySelector('button[type="submit"][data-sandals-target]')
640
+ if (!button || button.disabled || button.dataset.sandalsPending) return
641
+
642
+ event.preventDefault()
643
+ form.requestSubmit(button)
644
+ })
645
+
646
+ document.addEventListener("submit", (event) => {
647
+ const form = event.target.closest("form")
648
+ if (!form) return
649
+
650
+ event.preventDefault()
651
+ const button =
652
+ event.submitter ||
653
+ form.querySelector('button[type="submit"][data-sandals-target]')
654
+ if (!button) return
655
+ if (button.disabled || button.dataset.sandalsPending) return
656
+
657
+ markButtonPending(button)
658
+ enqueue({
659
+ target: button.dataset.sandalsTarget,
660
+ event: "submit"
661
+ })
662
+ })
663
+
664
+ document.addEventListener("visibilitychange", () => {
665
+ clearTimeout(refreshTimer)
666
+ if (!document.hidden) {
667
+ refreshView()
668
+ checkForCodeChanges()
669
+ }
670
+ })
671
+
672
+ scheduleRefresh()
673
+ if (developmentReload) {
674
+ checkForCodeChanges()
675
+ setInterval(checkForCodeChanges, 1000)
676
+ }
677
+ })
678
+ JAVASCRIPT
679
+ end
680
+
681
+ def escape(value)
682
+ CGI.escapeHTML(value.to_s)
683
+ end
684
+
685
+ end
686
+ end