@rip-lang/ui 0.3.0 → 0.3.1
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.
- package/README.md +587 -137
- package/accordion.rip +113 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/button.rip +23 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/combobox.rip +153 -0
- package/context-menu.rip +98 -0
- package/date-picker.rip +214 -0
- package/dialog.rip +107 -0
- package/drawer.rip +79 -0
- package/editable-value.rip +80 -0
- package/field.rip +53 -0
- package/fieldset.rip +22 -0
- package/form.rip +39 -0
- package/grid.rip +901 -0
- package/index.rip +15 -0
- package/input.rip +35 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/nav-menu.rip +132 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +16 -26
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/slider.rip +165 -0
- package/tabs.rip +124 -0
- package/toast.rip +88 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/serve.rip +0 -140
- package/ui.rip +0 -935
package/toast.rip
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Toast — accessible headless toast notification system
|
|
2
|
+
#
|
|
3
|
+
# Managed toast system with stacking, timer pause on hover, and promise support.
|
|
4
|
+
# Uses ARIA live region for screen reader announcements. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# toasts := []
|
|
8
|
+
#
|
|
9
|
+
# # Add a toast — reactive assignment is the API
|
|
10
|
+
# toasts = [...toasts, { message: "Saved!", type: "success" }]
|
|
11
|
+
#
|
|
12
|
+
# # Dismiss — filter it out
|
|
13
|
+
# toasts = toasts.filter (t) -> t isnt target
|
|
14
|
+
#
|
|
15
|
+
# # Clear all
|
|
16
|
+
# toasts = []
|
|
17
|
+
#
|
|
18
|
+
# # In render block
|
|
19
|
+
# ToastViewport toasts <=> toasts
|
|
20
|
+
|
|
21
|
+
export ToastViewport = component
|
|
22
|
+
@toasts := []
|
|
23
|
+
@placement := 'bottom-right'
|
|
24
|
+
|
|
25
|
+
onDismiss: (toast) ->
|
|
26
|
+
@toasts = @toasts.filter (t) -> t isnt toast
|
|
27
|
+
@emit 'dismiss', toast
|
|
28
|
+
|
|
29
|
+
render
|
|
30
|
+
div role: "region", aria-label: "Notifications", $placement: @placement
|
|
31
|
+
for toast in @toasts
|
|
32
|
+
Toast toast: toast, @dismiss: (e) => @onDismiss(e.detail)
|
|
33
|
+
|
|
34
|
+
export Toast = component
|
|
35
|
+
@toast := {}
|
|
36
|
+
|
|
37
|
+
leaving := false
|
|
38
|
+
_timer := null
|
|
39
|
+
_remaining = 0
|
|
40
|
+
_started = 0
|
|
41
|
+
|
|
42
|
+
_startTimer: ->
|
|
43
|
+
dur = @toast.duration ?? 4000
|
|
44
|
+
return unless dur > 0
|
|
45
|
+
_remaining = dur
|
|
46
|
+
_started = Date.now()
|
|
47
|
+
_timer = setTimeout => @dismiss(), dur
|
|
48
|
+
|
|
49
|
+
_pauseTimer: ->
|
|
50
|
+
return unless _timer
|
|
51
|
+
clearTimeout _timer
|
|
52
|
+
_remaining -= Date.now() - _started
|
|
53
|
+
_timer = null
|
|
54
|
+
|
|
55
|
+
_resumeTimer: ->
|
|
56
|
+
return if _timer or _remaining <= 0
|
|
57
|
+
_started = Date.now()
|
|
58
|
+
_timer = setTimeout => @dismiss(), _remaining
|
|
59
|
+
|
|
60
|
+
mounted: -> @_startTimer()
|
|
61
|
+
|
|
62
|
+
beforeUnmount: ->
|
|
63
|
+
clearTimeout _timer if _timer
|
|
64
|
+
|
|
65
|
+
dismiss: ->
|
|
66
|
+
leaving = true
|
|
67
|
+
setTimeout =>
|
|
68
|
+
@emit 'dismiss', @toast
|
|
69
|
+
, 200
|
|
70
|
+
|
|
71
|
+
render
|
|
72
|
+
div role: (if @toast.type is 'error' then 'alert' else 'status'),
|
|
73
|
+
aria-live: (if @toast.type is 'error' then 'assertive' else 'polite'),
|
|
74
|
+
$type: @toast.type ?? 'info',
|
|
75
|
+
$leaving: leaving?!,
|
|
76
|
+
@mouseenter: @_pauseTimer,
|
|
77
|
+
@mouseleave: @_resumeTimer,
|
|
78
|
+
@focusin: @_pauseTimer,
|
|
79
|
+
@focusout: @_resumeTimer
|
|
80
|
+
.
|
|
81
|
+
if @toast.title
|
|
82
|
+
strong @toast.title
|
|
83
|
+
span @toast.message
|
|
84
|
+
if @toast.action
|
|
85
|
+
button @click: @toast.action.onClick
|
|
86
|
+
@toast.action.label or 'Action'
|
|
87
|
+
button aria-label: "Dismiss", @click: @dismiss
|
|
88
|
+
"✕"
|
package/toggle-group.rip
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# ToggleGroup — accessible headless toggle group
|
|
2
|
+
#
|
|
3
|
+
# A set of two-state buttons where one or more can be pressed.
|
|
4
|
+
# Set @multiple to false for single-select (radio-like) behavior.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ToggleGroup value <=> alignment
|
|
9
|
+
# div $value: "left", "Left"
|
|
10
|
+
# div $value: "center", "Center"
|
|
11
|
+
# div $value: "right", "Right"
|
|
12
|
+
|
|
13
|
+
export ToggleGroup = component
|
|
14
|
+
@value := null
|
|
15
|
+
@disabled := false
|
|
16
|
+
@multiple := false
|
|
17
|
+
@orientation := 'horizontal'
|
|
18
|
+
|
|
19
|
+
_items ~=
|
|
20
|
+
return [] unless @_slot
|
|
21
|
+
Array.from(@_slot.querySelectorAll('[data-value]') or [])
|
|
22
|
+
|
|
23
|
+
_isPressed: (item) ->
|
|
24
|
+
val = item.dataset.value
|
|
25
|
+
if @multiple
|
|
26
|
+
Array.isArray(@value) and val in @value
|
|
27
|
+
else
|
|
28
|
+
val is @value
|
|
29
|
+
|
|
30
|
+
_toggle: (val) ->
|
|
31
|
+
return if @disabled
|
|
32
|
+
if @multiple
|
|
33
|
+
arr = if Array.isArray(@value) then [...@value] else []
|
|
34
|
+
if val in arr
|
|
35
|
+
arr = arr.filter (v) -> v isnt val
|
|
36
|
+
else
|
|
37
|
+
arr.push val
|
|
38
|
+
@value = arr
|
|
39
|
+
else
|
|
40
|
+
@value = if val is @value then null else val
|
|
41
|
+
@emit 'change', @value
|
|
42
|
+
|
|
43
|
+
onKeydown: (e) ->
|
|
44
|
+
opts = @_root?.querySelectorAll('[data-value]')
|
|
45
|
+
return unless opts?.length
|
|
46
|
+
focused = Array.from(opts).indexOf(document.activeElement)
|
|
47
|
+
return if focused < 0
|
|
48
|
+
len = opts.length
|
|
49
|
+
switch e.key
|
|
50
|
+
when 'ArrowRight', 'ArrowDown'
|
|
51
|
+
e.preventDefault()
|
|
52
|
+
opts[(focused + 1) %% len]?.focus()
|
|
53
|
+
when 'ArrowLeft', 'ArrowUp'
|
|
54
|
+
e.preventDefault()
|
|
55
|
+
opts[(focused - 1) %% len]?.focus()
|
|
56
|
+
when 'Home'
|
|
57
|
+
e.preventDefault()
|
|
58
|
+
opts[0]?.focus()
|
|
59
|
+
when 'End'
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
opts[len - 1]?.focus()
|
|
62
|
+
|
|
63
|
+
render
|
|
64
|
+
div ref: "_root", role: "group", aria-orientation: @orientation
|
|
65
|
+
$orientation: @orientation
|
|
66
|
+
$disabled: @disabled?!
|
|
67
|
+
|
|
68
|
+
. ref: "_slot", style: "display:none"
|
|
69
|
+
slot
|
|
70
|
+
|
|
71
|
+
for item, idx in _items
|
|
72
|
+
button tabindex: (if idx is 0 then "0" else "-1")
|
|
73
|
+
aria-pressed: !!@_isPressed(item)
|
|
74
|
+
$pressed: @_isPressed(item)?!
|
|
75
|
+
$disabled: @disabled?!
|
|
76
|
+
$value: item.dataset.value
|
|
77
|
+
@click: (=> @_toggle(item.dataset.value))
|
|
78
|
+
item.textContent
|
package/toggle.rip
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Toggle — accessible headless toggle button
|
|
2
|
+
#
|
|
3
|
+
# Stateful button that toggles pressed state on click.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Toggle pressed <=> isBold
|
|
8
|
+
# "Bold"
|
|
9
|
+
|
|
10
|
+
export Toggle = component
|
|
11
|
+
@pressed := false
|
|
12
|
+
@disabled := false
|
|
13
|
+
|
|
14
|
+
onClick: ->
|
|
15
|
+
return if @disabled
|
|
16
|
+
@pressed = not @pressed
|
|
17
|
+
@emit 'change', @pressed
|
|
18
|
+
|
|
19
|
+
render
|
|
20
|
+
button aria-pressed: !!@pressed
|
|
21
|
+
aria-disabled: @disabled?!
|
|
22
|
+
$pressed: @pressed?!
|
|
23
|
+
$disabled: @disabled?!
|
|
24
|
+
slot
|
package/toolbar.rip
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Toolbar — accessible headless toolbar
|
|
2
|
+
#
|
|
3
|
+
# Groups interactive controls with roving tabindex keyboard navigation.
|
|
4
|
+
# Arrow keys move focus between focusable children. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Toolbar
|
|
8
|
+
# Button @click: save, "Save"
|
|
9
|
+
# Button @click: undo, "Undo"
|
|
10
|
+
# Separator orientation: "vertical"
|
|
11
|
+
# Toggle pressed <=> isBold, "Bold"
|
|
12
|
+
|
|
13
|
+
export Toolbar = component
|
|
14
|
+
@orientation := 'horizontal'
|
|
15
|
+
@label := ''
|
|
16
|
+
|
|
17
|
+
_getFocusable: ->
|
|
18
|
+
return [] unless @_root
|
|
19
|
+
Array.from(@_root.querySelectorAll('button, [tabindex], input, select, textarea')).filter (el) ->
|
|
20
|
+
not el.disabled and el.offsetParent isnt null
|
|
21
|
+
|
|
22
|
+
onKeydown: (e) ->
|
|
23
|
+
els = @_getFocusable()
|
|
24
|
+
return unless els.length
|
|
25
|
+
focused = els.indexOf(document.activeElement)
|
|
26
|
+
return if focused < 0
|
|
27
|
+
len = els.length
|
|
28
|
+
horiz = @orientation is 'horizontal'
|
|
29
|
+
switch e.key
|
|
30
|
+
when (if horiz then 'ArrowRight' else 'ArrowDown')
|
|
31
|
+
e.preventDefault()
|
|
32
|
+
els[(focused + 1) %% len]?.focus()
|
|
33
|
+
when (if horiz then 'ArrowLeft' else 'ArrowUp')
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
els[(focused - 1) %% len]?.focus()
|
|
36
|
+
when 'Home'
|
|
37
|
+
e.preventDefault()
|
|
38
|
+
els[0]?.focus()
|
|
39
|
+
when 'End'
|
|
40
|
+
e.preventDefault()
|
|
41
|
+
els[len - 1]?.focus()
|
|
42
|
+
|
|
43
|
+
render
|
|
44
|
+
div role: "toolbar", aria-label: @label or undefined, aria-orientation: @orientation
|
|
45
|
+
$orientation: @orientation
|
|
46
|
+
slot
|
package/tooltip.rip
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# Tooltip — accessible headless tooltip with delay and positioning
|
|
2
|
+
#
|
|
3
|
+
# Shows on hover/focus with configurable delay. Uses aria-describedby.
|
|
4
|
+
# Exposes $open, $entering, $exiting. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Tooltip text: "Helpful info", placement: "top"
|
|
8
|
+
# button "Hover me"
|
|
9
|
+
|
|
10
|
+
lastCloseTime = 0
|
|
11
|
+
GROUP_TIMEOUT = 400
|
|
12
|
+
|
|
13
|
+
export Tooltip = component
|
|
14
|
+
@text := ''
|
|
15
|
+
@placement := 'top'
|
|
16
|
+
@delay := 300
|
|
17
|
+
@offset := 6
|
|
18
|
+
@hoverable := false
|
|
19
|
+
|
|
20
|
+
open := false
|
|
21
|
+
entering := false
|
|
22
|
+
exiting := false
|
|
23
|
+
_showTimer := null
|
|
24
|
+
_hideTimer := null
|
|
25
|
+
_id =! "tip-#{Math.random().toString(36).slice(2, 8)}"
|
|
26
|
+
|
|
27
|
+
show: ->
|
|
28
|
+
clearTimeout _hideTimer if _hideTimer
|
|
29
|
+
delay = if (Date.now() - lastCloseTime) < GROUP_TIMEOUT then 0 else @delay
|
|
30
|
+
_showTimer = setTimeout =>
|
|
31
|
+
open = true
|
|
32
|
+
entering = true
|
|
33
|
+
setTimeout =>
|
|
34
|
+
entering = false
|
|
35
|
+
@_position()
|
|
36
|
+
, 0
|
|
37
|
+
, delay
|
|
38
|
+
|
|
39
|
+
hide: ->
|
|
40
|
+
clearTimeout _showTimer if _showTimer
|
|
41
|
+
exiting = true
|
|
42
|
+
_hideTimer = setTimeout =>
|
|
43
|
+
open = false
|
|
44
|
+
exiting = false
|
|
45
|
+
lastCloseTime = Date.now()
|
|
46
|
+
, 150
|
|
47
|
+
|
|
48
|
+
_cancelHide: ->
|
|
49
|
+
clearTimeout _hideTimer if _hideTimer
|
|
50
|
+
exiting = false
|
|
51
|
+
|
|
52
|
+
_position: ->
|
|
53
|
+
return unless @_trigger and @_tip
|
|
54
|
+
tr = @_trigger.getBoundingClientRect()
|
|
55
|
+
fl = @_tip.getBoundingClientRect()
|
|
56
|
+
[side, align] = @placement.split('-')
|
|
57
|
+
align ?= 'center'
|
|
58
|
+
gap = @offset
|
|
59
|
+
|
|
60
|
+
x = switch side
|
|
61
|
+
when 'bottom', 'top'
|
|
62
|
+
switch align
|
|
63
|
+
when 'start' then tr.left
|
|
64
|
+
when 'end' then tr.right - fl.width
|
|
65
|
+
else tr.left + (tr.width - fl.width) / 2
|
|
66
|
+
when 'right' then tr.right + gap
|
|
67
|
+
when 'left' then tr.left - fl.width - gap
|
|
68
|
+
|
|
69
|
+
y = switch side
|
|
70
|
+
when 'bottom' then tr.bottom + gap
|
|
71
|
+
when 'top' then tr.top - fl.height - gap
|
|
72
|
+
when 'left', 'right'
|
|
73
|
+
switch align
|
|
74
|
+
when 'start' then tr.top
|
|
75
|
+
when 'end' then tr.bottom - fl.height
|
|
76
|
+
else tr.top + (tr.height - fl.height) / 2
|
|
77
|
+
|
|
78
|
+
if side is 'bottom' and y + fl.height > window.innerHeight
|
|
79
|
+
y = tr.top - fl.height - gap
|
|
80
|
+
if side is 'top' and y < 0
|
|
81
|
+
y = tr.bottom + gap
|
|
82
|
+
if side is 'right' and x + fl.width > window.innerWidth
|
|
83
|
+
x = tr.left - fl.width - gap
|
|
84
|
+
if side is 'left' and x < 0
|
|
85
|
+
x = tr.right + gap
|
|
86
|
+
|
|
87
|
+
x = Math.max(4, Math.min(x, window.innerWidth - fl.width - 4))
|
|
88
|
+
|
|
89
|
+
@_tip.style.position = 'fixed'
|
|
90
|
+
@_tip.style.left = "#{x}px"
|
|
91
|
+
@_tip.style.top = "#{y}px"
|
|
92
|
+
|
|
93
|
+
beforeUnmount: ->
|
|
94
|
+
clearTimeout _showTimer if _showTimer
|
|
95
|
+
clearTimeout _hideTimer if _hideTimer
|
|
96
|
+
|
|
97
|
+
render
|
|
98
|
+
.
|
|
99
|
+
div ref: "_trigger"
|
|
100
|
+
aria-describedby: open ? _id : undefined
|
|
101
|
+
@mouseenter: @show
|
|
102
|
+
@mouseleave: @hide
|
|
103
|
+
@focusin: @show
|
|
104
|
+
@focusout: @hide
|
|
105
|
+
slot
|
|
106
|
+
|
|
107
|
+
if open
|
|
108
|
+
div ref: "_tip", id: _id, role: "tooltip"
|
|
109
|
+
$open: true
|
|
110
|
+
$entering: entering?!
|
|
111
|
+
$exiting: exiting?!
|
|
112
|
+
$placement: @placement
|
|
113
|
+
@mouseenter: (=> @_cancelHide() if @hoverable)
|
|
114
|
+
@mouseleave: (=> @hide() if @hoverable)
|
|
115
|
+
@text
|
package/serve.rip
DELETED
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
# ==============================================================================
|
|
2
|
-
# @rip-lang/ui/serve — Rip UI Server Middleware
|
|
3
|
-
# ==============================================================================
|
|
4
|
-
#
|
|
5
|
-
# Serves the Rip UI runtime, auto-generated app bundles, and optional
|
|
6
|
-
# SSE hot-reload.
|
|
7
|
-
#
|
|
8
|
-
# Usage:
|
|
9
|
-
# import { ripUI } from '@rip-lang/ui/serve'
|
|
10
|
-
# use ripUI app: '/demo', dir: dir, title: 'My App'
|
|
11
|
-
#
|
|
12
|
-
# Options:
|
|
13
|
-
# app: string — URL mount point (default: '')
|
|
14
|
-
# dir: string — app directory on disk (default: '.')
|
|
15
|
-
# components: string — components subdirectory name (default: 'components')
|
|
16
|
-
# watch: boolean — enable SSE hot-reload endpoint (default: false)
|
|
17
|
-
# debounce: number — ms to batch filesystem events (default: 250)
|
|
18
|
-
# state: object — initial app state passed via bundle
|
|
19
|
-
# title: string — document title
|
|
20
|
-
#
|
|
21
|
-
# ==============================================================================
|
|
22
|
-
|
|
23
|
-
import { get } from '@rip-lang/api'
|
|
24
|
-
import { watch as fsWatch } from 'node:fs'
|
|
25
|
-
|
|
26
|
-
export ripUI = (opts = {}) ->
|
|
27
|
-
prefix = opts.app or ''
|
|
28
|
-
appDir = opts.dir or '.'
|
|
29
|
-
componentsDir = opts.components or "#{appDir}/components"
|
|
30
|
-
enableWatch = opts.watch or false
|
|
31
|
-
debounceMs = opts.debounce or 250
|
|
32
|
-
appState = opts.state or null
|
|
33
|
-
appTitle = opts.title or null
|
|
34
|
-
uiDir = import.meta.dir
|
|
35
|
-
|
|
36
|
-
# Resolve compiler (rip.browser.js)
|
|
37
|
-
compilerPath = null
|
|
38
|
-
try
|
|
39
|
-
compilerPath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip.browser.js'))
|
|
40
|
-
catch
|
|
41
|
-
compilerPath = "#{uiDir}/../../docs/dist/rip.browser.js"
|
|
42
|
-
|
|
43
|
-
# ----------------------------------------------------------------------------
|
|
44
|
-
# Route: /rip/* — framework files (compiler + ui.rip), registered once
|
|
45
|
-
# ----------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
unless ripUI._registered
|
|
48
|
-
get "/rip/browser.js", (c) -> c.send compilerPath, 'application/javascript'
|
|
49
|
-
get "/rip/ui.rip", (c) -> c.send "#{uiDir}/ui.rip", 'text/plain; charset=UTF-8'
|
|
50
|
-
ripUI._registered = true
|
|
51
|
-
|
|
52
|
-
# ----------------------------------------------------------------------------
|
|
53
|
-
# Route: {prefix}/components/* — individual .rip component files (for hot-reload)
|
|
54
|
-
# Route: {prefix}/bundle — app bundle (components + data as JSON)
|
|
55
|
-
# Route: {prefix}/watch — SSE hot-reload stream
|
|
56
|
-
# ----------------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
get "#{prefix}/components/*", (c) ->
|
|
59
|
-
name = c.req.path.slice("#{prefix}/components/".length)
|
|
60
|
-
c.send "#{componentsDir}/#{name}", 'text/plain; charset=UTF-8'
|
|
61
|
-
|
|
62
|
-
bundleCache = null
|
|
63
|
-
bundleDirty = true
|
|
64
|
-
|
|
65
|
-
# Invalidate bundle cache when components change
|
|
66
|
-
if enableWatch
|
|
67
|
-
fsWatch componentsDir, { recursive: true }, (event, filename) ->
|
|
68
|
-
bundleDirty = true if filename?.endsWith('.rip')
|
|
69
|
-
|
|
70
|
-
get "#{prefix}/bundle", (c) ->
|
|
71
|
-
if bundleDirty or not bundleCache
|
|
72
|
-
glob = new Bun.Glob("**/*.rip")
|
|
73
|
-
components = {}
|
|
74
|
-
paths = Array.from(glob.scanSync(componentsDir))
|
|
75
|
-
for path in paths
|
|
76
|
-
components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
|
|
77
|
-
|
|
78
|
-
data = {}
|
|
79
|
-
data.title = appTitle if appTitle
|
|
80
|
-
data.watch = enableWatch
|
|
81
|
-
if appState
|
|
82
|
-
data[k] = v for k, v of appState
|
|
83
|
-
|
|
84
|
-
bundleCache = JSON.stringify({ components, data })
|
|
85
|
-
bundleDirty = false
|
|
86
|
-
|
|
87
|
-
new Response bundleCache, headers: { 'Content-Type': 'application/json' }
|
|
88
|
-
|
|
89
|
-
if enableWatch
|
|
90
|
-
get "#{prefix}/watch", (c) ->
|
|
91
|
-
encoder = new TextEncoder()
|
|
92
|
-
pending = new Set()
|
|
93
|
-
timer = null
|
|
94
|
-
watcher = null
|
|
95
|
-
heartbeat = null
|
|
96
|
-
|
|
97
|
-
cleanup = ->
|
|
98
|
-
watcher?.close()
|
|
99
|
-
clearTimeout(timer) if timer
|
|
100
|
-
clearInterval(heartbeat) if heartbeat
|
|
101
|
-
watcher = heartbeat = timer = null
|
|
102
|
-
|
|
103
|
-
new Response new ReadableStream(
|
|
104
|
-
start: (controller) ->
|
|
105
|
-
send = (event, data) ->
|
|
106
|
-
try
|
|
107
|
-
controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
|
|
108
|
-
catch
|
|
109
|
-
cleanup()
|
|
110
|
-
|
|
111
|
-
send 'connected', { time: Date.now() }
|
|
112
|
-
|
|
113
|
-
heartbeat = setInterval ->
|
|
114
|
-
try
|
|
115
|
-
controller.enqueue encoder.encode(": heartbeat\n\n")
|
|
116
|
-
catch
|
|
117
|
-
cleanup()
|
|
118
|
-
, 5000
|
|
119
|
-
|
|
120
|
-
flush = ->
|
|
121
|
-
paths = Array.from(pending)
|
|
122
|
-
pending.clear()
|
|
123
|
-
timer = null
|
|
124
|
-
send('changed', { paths }) if paths.length > 0
|
|
125
|
-
|
|
126
|
-
watcher = fsWatch componentsDir, { recursive: true }, (event, filename) ->
|
|
127
|
-
return unless filename?.endsWith('.rip')
|
|
128
|
-
pending.add "components/#{filename}"
|
|
129
|
-
clearTimeout(timer) if timer
|
|
130
|
-
timer = setTimeout(flush, debounceMs)
|
|
131
|
-
|
|
132
|
-
cancel: -> cleanup()
|
|
133
|
-
),
|
|
134
|
-
headers:
|
|
135
|
-
'Content-Type': 'text/event-stream'
|
|
136
|
-
'Cache-Control': 'no-cache'
|
|
137
|
-
'Connection': 'keep-alive'
|
|
138
|
-
|
|
139
|
-
# Return pass-through middleware
|
|
140
|
-
(c, next) -> next!()
|