opal-vite 0.2.19 → 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.
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8736c0cccd990adbc7f614480718ba0bab767f94ad281f508b0a01b9898deb3d
|
|
4
|
+
data.tar.gz: 93ab376b58edb586aed577d0d4a19011f7f84660c62eebffe418eccb4b9b1e73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1c0d8a02384a2e1f958d2a0ce62f66fe8f63db8c87cbe709089e04ca9441e2d37a9a41b860170cdb2ca8f24467a79ce12b2587d1312e0d59dd22e75020429911
|
|
7
|
+
data.tar.gz: 7aae2b96e735993cfd583cdc98ea0ad6bbd49110ef63209b17f6f49e614bf7edc0d1948612f40522ed9aa6e1839fa6e02731a7f6764ef269b3b61c17cee5c2b3
|
data/lib/opal/vite/version.rb
CHANGED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# backtick_javascript: true
|
|
2
|
+
|
|
3
|
+
module OpalVite
|
|
4
|
+
module Concerns
|
|
5
|
+
module V1
|
|
6
|
+
# Base64Helpers - provides Base64 encoding/decoding utilities
|
|
7
|
+
#
|
|
8
|
+
# This module wraps JavaScript's btoa/atob and provides additional
|
|
9
|
+
# utilities for Base64 operations commonly needed in web applications.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class MyController < StimulusController
|
|
13
|
+
# include OpalVite::Concerns::V1::Base64Helpers
|
|
14
|
+
#
|
|
15
|
+
# def connect
|
|
16
|
+
# encoded = base64_encode("Hello, World!")
|
|
17
|
+
# puts encoded # => "SGVsbG8sIFdvcmxkIQ=="
|
|
18
|
+
#
|
|
19
|
+
# decoded = base64_decode(encoded)
|
|
20
|
+
# puts decoded # => "Hello, World!"
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
module Base64Helpers
|
|
25
|
+
# ===== Basic Encoding/Decoding =====
|
|
26
|
+
|
|
27
|
+
# Encode a string to Base64
|
|
28
|
+
# @param str [String] String to encode
|
|
29
|
+
# @return [String] Base64 encoded string
|
|
30
|
+
def base64_encode(str)
|
|
31
|
+
`btoa(#{str})`
|
|
32
|
+
rescue
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Decode a Base64 string
|
|
37
|
+
# @param str [String] Base64 string to decode
|
|
38
|
+
# @return [String] Decoded string
|
|
39
|
+
def base64_decode(str)
|
|
40
|
+
`atob(#{str})`
|
|
41
|
+
rescue
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ===== URL-Safe Base64 =====
|
|
46
|
+
|
|
47
|
+
# Encode a string to URL-safe Base64
|
|
48
|
+
# @param str [String] String to encode
|
|
49
|
+
# @return [String] URL-safe Base64 encoded string
|
|
50
|
+
def base64_encode_urlsafe(str)
|
|
51
|
+
encoded = base64_encode(str)
|
|
52
|
+
return nil unless encoded
|
|
53
|
+
|
|
54
|
+
# Replace + with -, / with _, and remove =
|
|
55
|
+
encoded.gsub('+', '-').gsub('/', '_').gsub('=', '')
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Decode a URL-safe Base64 string
|
|
59
|
+
# @param str [String] URL-safe Base64 string to decode
|
|
60
|
+
# @return [String] Decoded string
|
|
61
|
+
def base64_decode_urlsafe(str)
|
|
62
|
+
# Restore standard Base64 characters
|
|
63
|
+
standard = str.gsub('-', '+').gsub('_', '/')
|
|
64
|
+
|
|
65
|
+
# Add padding if needed
|
|
66
|
+
case standard.length % 4
|
|
67
|
+
when 2
|
|
68
|
+
standard += '=='
|
|
69
|
+
when 3
|
|
70
|
+
standard += '='
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
base64_decode(standard)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# ===== Unicode Support =====
|
|
77
|
+
|
|
78
|
+
# Encode a Unicode string to Base64
|
|
79
|
+
# @param str [String] Unicode string to encode
|
|
80
|
+
# @return [String] Base64 encoded string
|
|
81
|
+
def base64_encode_unicode(str)
|
|
82
|
+
# Convert to UTF-8 bytes, then encode
|
|
83
|
+
`btoa(unescape(encodeURIComponent(#{str})))`
|
|
84
|
+
rescue
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Decode a Base64 string to Unicode
|
|
89
|
+
# @param str [String] Base64 string to decode
|
|
90
|
+
# @return [String] Decoded Unicode string
|
|
91
|
+
def base64_decode_unicode(str)
|
|
92
|
+
`decodeURIComponent(escape(atob(#{str})))`
|
|
93
|
+
rescue
|
|
94
|
+
nil
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# ===== Binary Data (ArrayBuffer/Uint8Array) =====
|
|
98
|
+
|
|
99
|
+
# Encode an ArrayBuffer or Uint8Array to Base64
|
|
100
|
+
# @param buffer [Native] ArrayBuffer or Uint8Array
|
|
101
|
+
# @return [String] Base64 encoded string
|
|
102
|
+
def base64_encode_buffer(buffer)
|
|
103
|
+
`
|
|
104
|
+
var bytes = new Uint8Array(#{buffer});
|
|
105
|
+
var binary = '';
|
|
106
|
+
for (var i = 0; i < bytes.byteLength; i++) {
|
|
107
|
+
binary += String.fromCharCode(bytes[i]);
|
|
108
|
+
}
|
|
109
|
+
return btoa(binary);
|
|
110
|
+
`
|
|
111
|
+
rescue
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Decode a Base64 string to Uint8Array
|
|
116
|
+
# @param str [String] Base64 string to decode
|
|
117
|
+
# @return [Native] Uint8Array
|
|
118
|
+
def base64_decode_to_buffer(str)
|
|
119
|
+
`
|
|
120
|
+
var binary = atob(#{str});
|
|
121
|
+
var bytes = new Uint8Array(binary.length);
|
|
122
|
+
for (var i = 0; i < binary.length; i++) {
|
|
123
|
+
bytes[i] = binary.charCodeAt(i);
|
|
124
|
+
}
|
|
125
|
+
return bytes;
|
|
126
|
+
`
|
|
127
|
+
rescue
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ===== Data URL Support =====
|
|
132
|
+
|
|
133
|
+
# Create a data URL from content
|
|
134
|
+
# @param content [String] Content to encode
|
|
135
|
+
# @param mime_type [String] MIME type (default: "text/plain")
|
|
136
|
+
# @return [String] Data URL
|
|
137
|
+
def to_data_url(content, mime_type = 'text/plain')
|
|
138
|
+
encoded = base64_encode_unicode(content)
|
|
139
|
+
return nil unless encoded
|
|
140
|
+
|
|
141
|
+
"data:#{mime_type};base64,#{encoded}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Parse a data URL
|
|
145
|
+
# @param data_url [String] Data URL
|
|
146
|
+
# @return [Hash, nil] Hash with :mime_type and :data keys, or nil
|
|
147
|
+
def parse_data_url(data_url)
|
|
148
|
+
return nil unless data_url.to_s.start_with?('data:')
|
|
149
|
+
|
|
150
|
+
match = `#{data_url}.match(/^data:([^;]+);base64,(.+)$/)`
|
|
151
|
+
return nil if `#{match} === null`
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
mime_type: `#{match}[1]`,
|
|
155
|
+
data: base64_decode_unicode(`#{match}[2]`)
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# ===== Authentication Helpers =====
|
|
160
|
+
|
|
161
|
+
# Create a Basic Auth header value
|
|
162
|
+
# @param username [String] Username
|
|
163
|
+
# @param password [String] Password
|
|
164
|
+
# @return [String] Basic auth header value
|
|
165
|
+
def basic_auth_header(username, password)
|
|
166
|
+
credentials = "#{username}:#{password}"
|
|
167
|
+
encoded = base64_encode(credentials)
|
|
168
|
+
"Basic #{encoded}"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Parse a Basic Auth header value
|
|
172
|
+
# @param header [String] Authorization header value
|
|
173
|
+
# @return [Hash, nil] Hash with :username and :password keys, or nil
|
|
174
|
+
def parse_basic_auth(header)
|
|
175
|
+
return nil unless header.to_s.start_with?('Basic ')
|
|
176
|
+
|
|
177
|
+
encoded = header[6..-1]
|
|
178
|
+
decoded = base64_decode(encoded)
|
|
179
|
+
return nil unless decoded
|
|
180
|
+
|
|
181
|
+
parts = decoded.split(':', 2)
|
|
182
|
+
return nil if parts.length != 2
|
|
183
|
+
|
|
184
|
+
{
|
|
185
|
+
username: parts[0],
|
|
186
|
+
password: parts[1]
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# ===== JWT Helpers =====
|
|
191
|
+
|
|
192
|
+
# Decode a JWT payload (without verification)
|
|
193
|
+
# @param token [String] JWT token
|
|
194
|
+
# @return [Hash, nil] Decoded payload or nil
|
|
195
|
+
# @note This does NOT verify the signature - use only for reading claims
|
|
196
|
+
def decode_jwt_payload(token)
|
|
197
|
+
parts = token.to_s.split('.')
|
|
198
|
+
return nil if parts.length != 3
|
|
199
|
+
|
|
200
|
+
payload_base64 = parts[1]
|
|
201
|
+
payload_json = base64_decode_urlsafe(payload_base64)
|
|
202
|
+
return nil unless payload_json
|
|
203
|
+
|
|
204
|
+
`JSON.parse(#{payload_json})`
|
|
205
|
+
rescue
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Check if a JWT is expired
|
|
210
|
+
# @param token [String] JWT token
|
|
211
|
+
# @return [Boolean] True if expired
|
|
212
|
+
def jwt_expired?(token)
|
|
213
|
+
payload = decode_jwt_payload(token)
|
|
214
|
+
return true unless payload
|
|
215
|
+
|
|
216
|
+
exp = `#{payload}.exp`
|
|
217
|
+
return false if `#{exp} === undefined || #{exp} === null`
|
|
218
|
+
|
|
219
|
+
now = `Math.floor(Date.now() / 1000)`
|
|
220
|
+
`#{exp} < #{now}`
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Get JWT expiration time
|
|
224
|
+
# @param token [String] JWT token
|
|
225
|
+
# @return [Time, nil] Expiration time or nil
|
|
226
|
+
def jwt_expires_at(token)
|
|
227
|
+
payload = decode_jwt_payload(token)
|
|
228
|
+
return nil unless payload
|
|
229
|
+
|
|
230
|
+
exp = `#{payload}.exp`
|
|
231
|
+
return nil if `#{exp} === undefined || #{exp} === null`
|
|
232
|
+
|
|
233
|
+
# Convert Unix timestamp to Ruby-like time representation
|
|
234
|
+
`new Date(#{exp} * 1000)`
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# ===== Utility Methods =====
|
|
238
|
+
|
|
239
|
+
# Check if a string is valid Base64
|
|
240
|
+
# @param str [String] String to check
|
|
241
|
+
# @return [Boolean] True if valid Base64
|
|
242
|
+
def valid_base64?(str)
|
|
243
|
+
return false if str.nil? || str.empty?
|
|
244
|
+
|
|
245
|
+
# Check for valid Base64 characters
|
|
246
|
+
`
|
|
247
|
+
var regex = /^[A-Za-z0-9+/]*={0,2}$/;
|
|
248
|
+
if (!regex.test(#{str})) return false;
|
|
249
|
+
if (#{str}.length % 4 !== 0) return false;
|
|
250
|
+
try {
|
|
251
|
+
atob(#{str});
|
|
252
|
+
return true;
|
|
253
|
+
} catch (e) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
`
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Get the decoded length of a Base64 string
|
|
260
|
+
# @param str [String] Base64 string
|
|
261
|
+
# @return [Integer] Decoded length in bytes
|
|
262
|
+
def base64_decoded_length(str)
|
|
263
|
+
return 0 if str.nil? || str.empty?
|
|
264
|
+
|
|
265
|
+
len = str.length
|
|
266
|
+
padding = str.end_with?('==') ? 2 : (str.end_with?('=') ? 1 : 0)
|
|
267
|
+
(len * 3 / 4) - padding
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Alias for convenience
|
|
275
|
+
Base64Helpers = OpalVite::Concerns::V1::Base64Helpers
|
|
@@ -450,12 +450,7 @@ module OpalVite
|
|
|
450
450
|
# @param name [String] Event name
|
|
451
451
|
# @yield Block to execute when event fires
|
|
452
452
|
def on_controller_event(name, &block)
|
|
453
|
-
`
|
|
454
|
-
const handler = #{block};
|
|
455
|
-
this.element.addEventListener(#{name}, function(e) {
|
|
456
|
-
handler(e);
|
|
457
|
-
});
|
|
458
|
-
`
|
|
453
|
+
`this.element.addEventListener(#{name}, #{block})`
|
|
459
454
|
end
|
|
460
455
|
|
|
461
456
|
# Get the current event's target element
|
|
@@ -1307,6 +1302,643 @@ module OpalVite
|
|
|
1307
1302
|
`#{promise}.finally(#{block})`
|
|
1308
1303
|
end
|
|
1309
1304
|
|
|
1305
|
+
# ===== Stimulus Values API =====
|
|
1306
|
+
# Access Stimulus values defined with `static values = { name: Type }`
|
|
1307
|
+
# Note: These methods use "stimulus_value" prefix to avoid conflict with
|
|
1308
|
+
# get_value(element) which gets an element's value attribute.
|
|
1309
|
+
|
|
1310
|
+
# Get a Stimulus value
|
|
1311
|
+
# @param name [Symbol, String] Value name (e.g., :count, :url)
|
|
1312
|
+
# @return [Object] The value (auto-converted by Stimulus based on type)
|
|
1313
|
+
# @example
|
|
1314
|
+
# # With self.values = { count: :number, url: :string }
|
|
1315
|
+
# stimulus_value(:count) # => 0
|
|
1316
|
+
# stimulus_value(:url) # => "/api/items"
|
|
1317
|
+
def stimulus_value(name)
|
|
1318
|
+
prop_name = "#{camelize(name, false)}Value"
|
|
1319
|
+
`this[#{prop_name}]`
|
|
1320
|
+
end
|
|
1321
|
+
|
|
1322
|
+
# Set a Stimulus value
|
|
1323
|
+
# @param name [Symbol, String] Value name
|
|
1324
|
+
# @param value [Object] The value to set
|
|
1325
|
+
# @example
|
|
1326
|
+
# set_stimulus_value(:count, 5)
|
|
1327
|
+
# set_stimulus_value(:items, [1, 2, 3])
|
|
1328
|
+
def set_stimulus_value(name, value)
|
|
1329
|
+
prop_name = "#{camelize(name, false)}Value"
|
|
1330
|
+
native_value = value.respond_to?(:to_n) ? value.to_n : value
|
|
1331
|
+
`this[#{prop_name}] = #{native_value}`
|
|
1332
|
+
end
|
|
1333
|
+
|
|
1334
|
+
# Check if a Stimulus value exists (has data attribute)
|
|
1335
|
+
# @param name [Symbol, String] Value name
|
|
1336
|
+
# @return [Boolean] true if value's data attribute exists
|
|
1337
|
+
# @example
|
|
1338
|
+
# if has_stimulus_value?(:api_url)
|
|
1339
|
+
# fetch_json(stimulus_value(:api_url)) { |data| ... }
|
|
1340
|
+
# end
|
|
1341
|
+
def has_stimulus_value?(name)
|
|
1342
|
+
prop_name = "has#{camelize(name)}Value"
|
|
1343
|
+
`this[#{prop_name}]`
|
|
1344
|
+
end
|
|
1345
|
+
|
|
1346
|
+
# Increment a numeric Stimulus value
|
|
1347
|
+
# @param name [Symbol, String] Value name
|
|
1348
|
+
# @param amount [Number] Amount to increment (default: 1)
|
|
1349
|
+
def increment_stimulus_value(name, amount = 1)
|
|
1350
|
+
set_stimulus_value(name, stimulus_value(name) + amount)
|
|
1351
|
+
end
|
|
1352
|
+
|
|
1353
|
+
# Decrement a numeric Stimulus value
|
|
1354
|
+
# @param name [Symbol, String] Value name
|
|
1355
|
+
# @param amount [Number] Amount to decrement (default: 1)
|
|
1356
|
+
def decrement_stimulus_value(name, amount = 1)
|
|
1357
|
+
set_stimulus_value(name, stimulus_value(name) - amount)
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
# Toggle a boolean Stimulus value
|
|
1361
|
+
# @param name [Symbol, String] Value name
|
|
1362
|
+
def toggle_stimulus_value(name)
|
|
1363
|
+
set_stimulus_value(name, !stimulus_value(name))
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# ===== Stimulus CSS Classes API =====
|
|
1367
|
+
# Access CSS classes defined with `static classes = [ "loading", "active" ]`
|
|
1368
|
+
|
|
1369
|
+
# Get a Stimulus CSS class (singular)
|
|
1370
|
+
# @param name [Symbol, String] Class logical name
|
|
1371
|
+
# @return [String] The CSS class name
|
|
1372
|
+
# @example
|
|
1373
|
+
# # With static classes = [ "loading" ] and data-controller-loading-class="spinner"
|
|
1374
|
+
# get_class(:loading) # => "spinner"
|
|
1375
|
+
def get_class(name)
|
|
1376
|
+
prop_name = "#{camelize(name, false)}Class"
|
|
1377
|
+
`this[#{prop_name}]`
|
|
1378
|
+
end
|
|
1379
|
+
|
|
1380
|
+
# Get all Stimulus CSS classes (plural, for space-separated values)
|
|
1381
|
+
# @param name [Symbol, String] Class logical name
|
|
1382
|
+
# @return [Array] Array of CSS class names
|
|
1383
|
+
# @example
|
|
1384
|
+
# # With data-controller-loading-class="spinner bg-gray-500"
|
|
1385
|
+
# get_classes(:loading) # => ["spinner", "bg-gray-500"]
|
|
1386
|
+
def get_classes(name)
|
|
1387
|
+
prop_name = "#{camelize(name, false)}Classes"
|
|
1388
|
+
`Array.from(this[#{prop_name}] || [])`
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1391
|
+
# Check if a Stimulus CSS class is defined
|
|
1392
|
+
# @param name [Symbol, String] Class logical name
|
|
1393
|
+
# @return [Boolean] true if class data attribute exists
|
|
1394
|
+
def has_class_definition?(name)
|
|
1395
|
+
prop_name = "has#{camelize(name)}Class"
|
|
1396
|
+
`this[#{prop_name}]`
|
|
1397
|
+
end
|
|
1398
|
+
|
|
1399
|
+
# Apply a Stimulus CSS class to an element
|
|
1400
|
+
# @param element [Native] DOM element
|
|
1401
|
+
# @param name [Symbol, String] Class logical name
|
|
1402
|
+
def apply_class(element, name)
|
|
1403
|
+
el = to_native_element(element)
|
|
1404
|
+
class_name = get_class(name)
|
|
1405
|
+
`#{el}.classList.add(#{class_name})` if class_name
|
|
1406
|
+
end
|
|
1407
|
+
|
|
1408
|
+
# Apply all Stimulus CSS classes to an element
|
|
1409
|
+
# @param element [Native] DOM element
|
|
1410
|
+
# @param name [Symbol, String] Class logical name
|
|
1411
|
+
def apply_classes(element, name)
|
|
1412
|
+
el = to_native_element(element)
|
|
1413
|
+
classes = get_classes(name)
|
|
1414
|
+
`#{el}.classList.add(...#{classes})` if classes
|
|
1415
|
+
end
|
|
1416
|
+
|
|
1417
|
+
# Remove a Stimulus CSS class from an element
|
|
1418
|
+
# @param element [Native] DOM element
|
|
1419
|
+
# @param name [Symbol, String] Class logical name
|
|
1420
|
+
def remove_applied_class(element, name)
|
|
1421
|
+
el = to_native_element(element)
|
|
1422
|
+
class_name = get_class(name)
|
|
1423
|
+
`#{el}.classList.remove(#{class_name})` if class_name
|
|
1424
|
+
end
|
|
1425
|
+
|
|
1426
|
+
# Remove all Stimulus CSS classes from an element
|
|
1427
|
+
# @param element [Native] DOM element
|
|
1428
|
+
# @param name [Symbol, String] Class logical name
|
|
1429
|
+
def remove_applied_classes(element, name)
|
|
1430
|
+
el = to_native_element(element)
|
|
1431
|
+
classes = get_classes(name)
|
|
1432
|
+
`#{el}.classList.remove(...#{classes})` if classes
|
|
1433
|
+
end
|
|
1434
|
+
|
|
1435
|
+
# ===== Stimulus Outlets API =====
|
|
1436
|
+
# Access outlets defined with `static outlets = [ "result" ]`
|
|
1437
|
+
|
|
1438
|
+
# Check if an outlet exists
|
|
1439
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1440
|
+
# @return [Boolean] true if outlet is connected
|
|
1441
|
+
# @example
|
|
1442
|
+
# if has_outlet?(:modal)
|
|
1443
|
+
# get_outlet(:modal).open
|
|
1444
|
+
# end
|
|
1445
|
+
def has_outlet?(name)
|
|
1446
|
+
prop_name = "has#{camelize(name)}Outlet"
|
|
1447
|
+
`this[#{prop_name}]`
|
|
1448
|
+
end
|
|
1449
|
+
|
|
1450
|
+
# Get a single outlet controller instance
|
|
1451
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1452
|
+
# @return [Native] The outlet controller instance
|
|
1453
|
+
# @note Throws error if outlet doesn't exist - use has_outlet? first
|
|
1454
|
+
def get_outlet(name)
|
|
1455
|
+
prop_name = "#{camelize(name, false)}Outlet"
|
|
1456
|
+
`this[#{prop_name}]`
|
|
1457
|
+
end
|
|
1458
|
+
|
|
1459
|
+
# Get all outlet controller instances
|
|
1460
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1461
|
+
# @return [Array] Array of outlet controller instances
|
|
1462
|
+
def get_outlets(name)
|
|
1463
|
+
prop_name = "#{camelize(name, false)}Outlets"
|
|
1464
|
+
`Array.from(this[#{prop_name}] || [])`
|
|
1465
|
+
end
|
|
1466
|
+
|
|
1467
|
+
# Get a single outlet's element
|
|
1468
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1469
|
+
# @return [Native] The outlet's controller element
|
|
1470
|
+
# @note Throws error if outlet doesn't exist - use has_outlet? first
|
|
1471
|
+
def get_outlet_element(name)
|
|
1472
|
+
prop_name = "#{camelize(name, false)}OutletElement"
|
|
1473
|
+
`this[#{prop_name}]`
|
|
1474
|
+
end
|
|
1475
|
+
|
|
1476
|
+
# Get all outlet elements
|
|
1477
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1478
|
+
# @return [Array] Array of outlet controller elements
|
|
1479
|
+
def get_outlet_elements(name)
|
|
1480
|
+
prop_name = "#{camelize(name, false)}OutletElements"
|
|
1481
|
+
`Array.from(this[#{prop_name}] || [])`
|
|
1482
|
+
end
|
|
1483
|
+
|
|
1484
|
+
# Call a method on an outlet controller
|
|
1485
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1486
|
+
# @param method [Symbol, String] Method name to call
|
|
1487
|
+
# @param args [Array] Arguments to pass
|
|
1488
|
+
# @return [Object] Method return value
|
|
1489
|
+
def call_outlet(name, method, *args)
|
|
1490
|
+
return nil unless has_outlet?(name)
|
|
1491
|
+
outlet = get_outlet(name)
|
|
1492
|
+
js_call_on(outlet, method, *args)
|
|
1493
|
+
end
|
|
1494
|
+
|
|
1495
|
+
# Call a method on all outlet controllers
|
|
1496
|
+
# @param name [Symbol, String] Outlet identifier
|
|
1497
|
+
# @param method [Symbol, String] Method name to call
|
|
1498
|
+
# @param args [Array] Arguments to pass
|
|
1499
|
+
def call_all_outlets(name, method, *args)
|
|
1500
|
+
get_outlets(name).each do |outlet|
|
|
1501
|
+
js_call_on(outlet, method, *args)
|
|
1502
|
+
end
|
|
1503
|
+
end
|
|
1504
|
+
|
|
1505
|
+
# ===== Stimulus dispatch() API =====
|
|
1506
|
+
# Emit custom events with controller identifier prefix
|
|
1507
|
+
|
|
1508
|
+
# Dispatch a Stimulus-style custom event
|
|
1509
|
+
# @param name [String] Event name (will be prefixed with controller identifier)
|
|
1510
|
+
# @param detail [Hash] Event detail data
|
|
1511
|
+
# @param target [Native, nil] Target element (default: controller element)
|
|
1512
|
+
# @param prefix [String, nil] Custom prefix (default: controller identifier)
|
|
1513
|
+
# @param bubbles [Boolean] Whether event bubbles (default: true)
|
|
1514
|
+
# @param cancelable [Boolean] Whether event is cancelable (default: true)
|
|
1515
|
+
# @return [Native] The dispatched event
|
|
1516
|
+
# @example
|
|
1517
|
+
# # In a "clipboard" controller:
|
|
1518
|
+
# stimulus_dispatch("copied", detail: { content: text })
|
|
1519
|
+
# # Dispatches "clipboard:copied" event
|
|
1520
|
+
def stimulus_dispatch(name, detail: {}, target: nil, prefix: nil, bubbles: true, cancelable: true)
|
|
1521
|
+
native_detail = detail.respond_to?(:to_n) ? detail.to_n : detail
|
|
1522
|
+
# Build options object, only including target/prefix if explicitly provided
|
|
1523
|
+
# Note: Ruby nil becomes Opal.nil in JS (truthy), so we must check explicitly
|
|
1524
|
+
options = `{ detail: #{native_detail}, bubbles: #{bubbles}, cancelable: #{cancelable} }`
|
|
1525
|
+
`#{options}.target = #{target}` unless target.nil?
|
|
1526
|
+
`#{options}.prefix = #{prefix}` unless prefix.nil?
|
|
1527
|
+
`this.dispatch(#{name}, #{options})`
|
|
1528
|
+
end
|
|
1529
|
+
|
|
1530
|
+
# Dispatch event and check if it was cancelled
|
|
1531
|
+
# @param name [String] Event name
|
|
1532
|
+
# @param detail [Hash] Event detail data
|
|
1533
|
+
# @return [Boolean] true if event was NOT cancelled
|
|
1534
|
+
def stimulus_dispatch_confirm(name, detail: {})
|
|
1535
|
+
event = stimulus_dispatch(name, detail: detail, cancelable: true)
|
|
1536
|
+
`!#{event}.defaultPrevented`
|
|
1537
|
+
end
|
|
1538
|
+
|
|
1539
|
+
# ===== Stimulus Action Parameters API =====
|
|
1540
|
+
# Access action parameters from data-[identifier]-[name]-param attributes
|
|
1541
|
+
|
|
1542
|
+
# Get all action parameters from the current event
|
|
1543
|
+
# @return [Native] JavaScript object with all parameters
|
|
1544
|
+
# @example
|
|
1545
|
+
# # <button data-action="item#delete" data-item-id-param="123">
|
|
1546
|
+
# def delete(event)
|
|
1547
|
+
# params = action_params # => { id: 123 }
|
|
1548
|
+
# end
|
|
1549
|
+
def action_params
|
|
1550
|
+
`event.params || {}`
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
# Get a specific action parameter
|
|
1554
|
+
# @param name [Symbol, String] Parameter name
|
|
1555
|
+
# @return [Object] Parameter value (auto-typecast by Stimulus)
|
|
1556
|
+
# @example
|
|
1557
|
+
# action_param(:id) # => 123 (Number)
|
|
1558
|
+
# action_param(:url) # => "/api/item" (String)
|
|
1559
|
+
def action_param(name)
|
|
1560
|
+
`(event.params || {})[#{name.to_s}]`
|
|
1561
|
+
end
|
|
1562
|
+
|
|
1563
|
+
# Get action parameter as integer with default
|
|
1564
|
+
# @param name [Symbol, String] Parameter name
|
|
1565
|
+
# @param default [Integer] Default value if missing or NaN
|
|
1566
|
+
# @return [Integer] Parameter value
|
|
1567
|
+
def action_param_int(name, default = 0)
|
|
1568
|
+
value = action_param(name)
|
|
1569
|
+
result = parse_int(value)
|
|
1570
|
+
is_nan?(result) ? default : result
|
|
1571
|
+
end
|
|
1572
|
+
|
|
1573
|
+
# Get action parameter as boolean
|
|
1574
|
+
# @param name [Symbol, String] Parameter name
|
|
1575
|
+
# @return [Boolean] Parameter value
|
|
1576
|
+
def action_param_bool(name)
|
|
1577
|
+
value = action_param(name)
|
|
1578
|
+
`!!#{value} && #{value} !== "false" && #{value} !== "0"`
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
# Check if an action parameter exists
|
|
1582
|
+
# @param name [Symbol, String] Parameter name
|
|
1583
|
+
# @return [Boolean] true if parameter exists
|
|
1584
|
+
def has_action_param?(name)
|
|
1585
|
+
`(event.params || {}).hasOwnProperty(#{name.to_s})`
|
|
1586
|
+
end
|
|
1587
|
+
|
|
1588
|
+
# ===== Stimulus Controller Access =====
|
|
1589
|
+
# Access controller properties and other controllers
|
|
1590
|
+
|
|
1591
|
+
# Get the controller's application instance
|
|
1592
|
+
# @return [Native] Stimulus Application
|
|
1593
|
+
def this_application
|
|
1594
|
+
`this.application`
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# Get the controller's identifier
|
|
1598
|
+
# @return [String] Controller identifier (e.g., "modal", "tabs")
|
|
1599
|
+
def this_identifier
|
|
1600
|
+
`this.identifier`
|
|
1601
|
+
end
|
|
1602
|
+
|
|
1603
|
+
# Get the controller's element
|
|
1604
|
+
# @return [Native] Controller's root element
|
|
1605
|
+
def this_element
|
|
1606
|
+
`this.element`
|
|
1607
|
+
end
|
|
1608
|
+
|
|
1609
|
+
# Get another controller instance by element and identifier
|
|
1610
|
+
# @param element [Native] DOM element with the controller
|
|
1611
|
+
# @param identifier [String] Controller identifier
|
|
1612
|
+
# @return [Native, nil] Controller instance or nil
|
|
1613
|
+
# @example
|
|
1614
|
+
# modal = get_controller(modal_element, "modal")
|
|
1615
|
+
# modal.open if modal
|
|
1616
|
+
def get_controller(element, identifier)
|
|
1617
|
+
el = to_native_element(element)
|
|
1618
|
+
`this.application.getControllerForElementAndIdentifier(#{el}, #{identifier})`
|
|
1619
|
+
end
|
|
1620
|
+
|
|
1621
|
+
# Get all controllers of a type within an element's scope
|
|
1622
|
+
# @param element [Native] Container element
|
|
1623
|
+
# @param identifier [String] Controller identifier
|
|
1624
|
+
# @return [Array] Array of controller instances
|
|
1625
|
+
def get_controllers(element, identifier)
|
|
1626
|
+
el = to_native_element(element)
|
|
1627
|
+
`Array.from(#{el}.querySelectorAll('[data-controller~="' + #{identifier} + '"]'))
|
|
1628
|
+
.map(el => this.application.getControllerForElementAndIdentifier(el, #{identifier}))
|
|
1629
|
+
.filter(c => c !== null)`
|
|
1630
|
+
end
|
|
1631
|
+
|
|
1632
|
+
# ===== Stimulus Scope Helpers =====
|
|
1633
|
+
|
|
1634
|
+
# Get the controller's scope (element and descendants, excluding nested controllers)
|
|
1635
|
+
# @return [Native] Controller's scope element
|
|
1636
|
+
def this_scope
|
|
1637
|
+
`this.scope.element`
|
|
1638
|
+
end
|
|
1639
|
+
|
|
1640
|
+
# Check if an element is within this controller's scope
|
|
1641
|
+
# @param element [Native] Element to check
|
|
1642
|
+
# @return [Boolean] true if element is in scope
|
|
1643
|
+
def in_scope?(element)
|
|
1644
|
+
el = to_native_element(element)
|
|
1645
|
+
`this.element.contains(#{el})`
|
|
1646
|
+
end
|
|
1647
|
+
|
|
1648
|
+
# ===== Debounce/Throttle Utilities =====
|
|
1649
|
+
|
|
1650
|
+
# Create a debounced version of a block
|
|
1651
|
+
# @param wait [Integer] Milliseconds to wait
|
|
1652
|
+
# @yield Block to debounce
|
|
1653
|
+
# @return [Native] Debounced function
|
|
1654
|
+
def debounce(wait, &block)
|
|
1655
|
+
`
|
|
1656
|
+
var timeout;
|
|
1657
|
+
return function(...args) {
|
|
1658
|
+
clearTimeout(timeout);
|
|
1659
|
+
timeout = setTimeout(() => #{block.call}, wait);
|
|
1660
|
+
};
|
|
1661
|
+
`
|
|
1662
|
+
end
|
|
1663
|
+
|
|
1664
|
+
# Create a throttled version of a block
|
|
1665
|
+
# @param wait [Integer] Milliseconds between calls
|
|
1666
|
+
# @yield Block to throttle
|
|
1667
|
+
# @return [Native] Throttled function
|
|
1668
|
+
def throttle(wait, &block)
|
|
1669
|
+
`
|
|
1670
|
+
var lastTime = 0;
|
|
1671
|
+
return function(...args) {
|
|
1672
|
+
var now = Date.now();
|
|
1673
|
+
if (now - lastTime >= #{wait}) {
|
|
1674
|
+
lastTime = now;
|
|
1675
|
+
#{block.call};
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
`
|
|
1679
|
+
end
|
|
1680
|
+
|
|
1681
|
+
# Debounce a method call
|
|
1682
|
+
# @param wait [Integer] Milliseconds to wait
|
|
1683
|
+
# @param key [String] Unique key for this debounce
|
|
1684
|
+
# @yield Block to execute after debounce
|
|
1685
|
+
def debounced(wait, key = 'default', &block)
|
|
1686
|
+
@_debounce_timers ||= {}
|
|
1687
|
+
timer_key = "_debounce_#{key}"
|
|
1688
|
+
|
|
1689
|
+
`clearTimeout(#{@_debounce_timers[timer_key]})`
|
|
1690
|
+
@_debounce_timers[timer_key] = `setTimeout(function() { #{block.call} }, #{wait})`
|
|
1691
|
+
end
|
|
1692
|
+
|
|
1693
|
+
# Throttle a method call
|
|
1694
|
+
# @param wait [Integer] Milliseconds between calls
|
|
1695
|
+
# @param key [String] Unique key for this throttle
|
|
1696
|
+
# @yield Block to execute if not throttled
|
|
1697
|
+
def throttled(wait, key = 'default', &block)
|
|
1698
|
+
@_throttle_times ||= {}
|
|
1699
|
+
time_key = "_throttle_#{key}"
|
|
1700
|
+
|
|
1701
|
+
now = `Date.now()`
|
|
1702
|
+
last_time = @_throttle_times[time_key] || 0
|
|
1703
|
+
|
|
1704
|
+
if `#{now} - #{last_time} >= #{wait}`
|
|
1705
|
+
@_throttle_times[time_key] = now
|
|
1706
|
+
block.call
|
|
1707
|
+
end
|
|
1708
|
+
end
|
|
1709
|
+
|
|
1710
|
+
# ===== Clipboard Utilities =====
|
|
1711
|
+
|
|
1712
|
+
# Copy text to clipboard
|
|
1713
|
+
# @param text [String] Text to copy
|
|
1714
|
+
# @yield Optional callback on success
|
|
1715
|
+
def copy_to_clipboard(text, &on_success)
|
|
1716
|
+
`navigator.clipboard.writeText(#{text}).then(function() {
|
|
1717
|
+
#{on_success.call if on_success}
|
|
1718
|
+
})`
|
|
1719
|
+
end
|
|
1720
|
+
|
|
1721
|
+
# Read text from clipboard
|
|
1722
|
+
# @yield Block receiving clipboard text
|
|
1723
|
+
def read_from_clipboard(&block)
|
|
1724
|
+
`navigator.clipboard.readText().then(function(text) {
|
|
1725
|
+
#{block.call(`text`)}
|
|
1726
|
+
})`
|
|
1727
|
+
end
|
|
1728
|
+
|
|
1729
|
+
# ===== Object Utilities =====
|
|
1730
|
+
|
|
1731
|
+
# Deep clone an object
|
|
1732
|
+
# @param obj [Object] Object to clone
|
|
1733
|
+
# @return [Object] Cloned object
|
|
1734
|
+
def deep_clone(obj)
|
|
1735
|
+
`JSON.parse(JSON.stringify(#{obj.to_n}))`
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
# Deep merge two objects
|
|
1739
|
+
# @param target [Hash] Target object
|
|
1740
|
+
# @param source [Hash] Source object
|
|
1741
|
+
# @return [Hash] Merged object
|
|
1742
|
+
def deep_merge(target, source)
|
|
1743
|
+
`
|
|
1744
|
+
function deepMerge(target, source) {
|
|
1745
|
+
var result = Object.assign({}, target);
|
|
1746
|
+
for (var key in source) {
|
|
1747
|
+
if (source.hasOwnProperty(key)) {
|
|
1748
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
1749
|
+
result[key] = deepMerge(result[key] || {}, source[key]);
|
|
1750
|
+
} else {
|
|
1751
|
+
result[key] = source[key];
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
return result;
|
|
1756
|
+
}
|
|
1757
|
+
return deepMerge(#{target.to_n}, #{source.to_n});
|
|
1758
|
+
`
|
|
1759
|
+
end
|
|
1760
|
+
|
|
1761
|
+
# Pick specific keys from an object
|
|
1762
|
+
# @param obj [Hash] Source object
|
|
1763
|
+
# @param keys [Array<String, Symbol>] Keys to pick
|
|
1764
|
+
# @return [Hash] Object with only specified keys
|
|
1765
|
+
def pick(obj, *keys)
|
|
1766
|
+
result = {}
|
|
1767
|
+
keys.flatten.each do |key|
|
|
1768
|
+
key_s = key.to_s
|
|
1769
|
+
native_obj = obj.to_n
|
|
1770
|
+
if `#{native_obj}.hasOwnProperty(#{key_s})`
|
|
1771
|
+
result[key_s] = `#{native_obj}[#{key_s}]`
|
|
1772
|
+
end
|
|
1773
|
+
end
|
|
1774
|
+
result
|
|
1775
|
+
end
|
|
1776
|
+
|
|
1777
|
+
# Omit specific keys from an object
|
|
1778
|
+
# @param obj [Hash] Source object
|
|
1779
|
+
# @param keys [Array<String, Symbol>] Keys to omit
|
|
1780
|
+
# @return [Hash] Object without specified keys
|
|
1781
|
+
def omit(obj, *keys)
|
|
1782
|
+
keys_to_omit = keys.flatten.map(&:to_s)
|
|
1783
|
+
result = {}
|
|
1784
|
+
native_obj = obj.to_n
|
|
1785
|
+
`Object.keys(#{native_obj}).forEach(function(key) {
|
|
1786
|
+
if (!#{keys_to_omit}.includes(key)) {
|
|
1787
|
+
#{result[`key`] = `#{native_obj}[key]`}
|
|
1788
|
+
}
|
|
1789
|
+
})`
|
|
1790
|
+
result
|
|
1791
|
+
end
|
|
1792
|
+
|
|
1793
|
+
# ===== Set Utilities =====
|
|
1794
|
+
|
|
1795
|
+
# Create a unique array (like Set)
|
|
1796
|
+
# @param arr [Array] Array with possible duplicates
|
|
1797
|
+
# @return [Array] Array with unique values
|
|
1798
|
+
def unique(arr)
|
|
1799
|
+
`[...new Set(#{arr.to_n})]`
|
|
1800
|
+
end
|
|
1801
|
+
|
|
1802
|
+
# Get intersection of two arrays
|
|
1803
|
+
# @param arr1 [Array] First array
|
|
1804
|
+
# @param arr2 [Array] Second array
|
|
1805
|
+
# @return [Array] Intersection
|
|
1806
|
+
def intersection(arr1, arr2)
|
|
1807
|
+
set2 = `new Set(#{arr2.to_n})`
|
|
1808
|
+
`#{arr1.to_n}.filter(x => #{set2}.has(x))`
|
|
1809
|
+
end
|
|
1810
|
+
|
|
1811
|
+
# Get difference of two arrays (arr1 - arr2)
|
|
1812
|
+
# @param arr1 [Array] First array
|
|
1813
|
+
# @param arr2 [Array] Second array
|
|
1814
|
+
# @return [Array] Difference
|
|
1815
|
+
def difference(arr1, arr2)
|
|
1816
|
+
set2 = `new Set(#{arr2.to_n})`
|
|
1817
|
+
`#{arr1.to_n}.filter(x => !#{set2}.has(x))`
|
|
1818
|
+
end
|
|
1819
|
+
|
|
1820
|
+
# Get union of two arrays
|
|
1821
|
+
# @param arr1 [Array] First array
|
|
1822
|
+
# @param arr2 [Array] Second array
|
|
1823
|
+
# @return [Array] Union (unique values)
|
|
1824
|
+
def union(arr1, arr2)
|
|
1825
|
+
`[...new Set([...#{arr1.to_n}, ...#{arr2.to_n}])]`
|
|
1826
|
+
end
|
|
1827
|
+
|
|
1828
|
+
# ===== Validation Utilities =====
|
|
1829
|
+
|
|
1830
|
+
# Validate an email address
|
|
1831
|
+
# @param email [String] Email to validate
|
|
1832
|
+
# @return [Boolean] True if valid
|
|
1833
|
+
def valid_email?(email)
|
|
1834
|
+
`/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(#{email})`
|
|
1835
|
+
end
|
|
1836
|
+
|
|
1837
|
+
# Validate a URL
|
|
1838
|
+
# @param url [String] URL to validate
|
|
1839
|
+
# @return [Boolean] True if valid
|
|
1840
|
+
def valid_url?(url)
|
|
1841
|
+
`
|
|
1842
|
+
try {
|
|
1843
|
+
new URL(#{url});
|
|
1844
|
+
return true;
|
|
1845
|
+
} catch (e) {
|
|
1846
|
+
return false;
|
|
1847
|
+
}
|
|
1848
|
+
`
|
|
1849
|
+
end
|
|
1850
|
+
|
|
1851
|
+
# Validate a phone number (basic)
|
|
1852
|
+
# @param phone [String] Phone number to validate
|
|
1853
|
+
# @return [Boolean] True if valid
|
|
1854
|
+
def valid_phone?(phone)
|
|
1855
|
+
# Remove common separators and check for digits
|
|
1856
|
+
cleaned = `#{phone}.replace(/[\s\-\(\)\.]/g, '')`
|
|
1857
|
+
`
|
|
1858
|
+
var pattern = /^[\+]?[0-9]{10,15}$/;
|
|
1859
|
+
return pattern.test(#{cleaned});
|
|
1860
|
+
`
|
|
1861
|
+
end
|
|
1862
|
+
|
|
1863
|
+
# Check if a value is blank (nil, empty string, or whitespace only)
|
|
1864
|
+
# @param value [Object] Value to check
|
|
1865
|
+
# @return [Boolean] True if blank
|
|
1866
|
+
def blank?(value)
|
|
1867
|
+
return true if value.nil?
|
|
1868
|
+
return `#{value}.trim() === ''` if `typeof #{value} === 'string'`
|
|
1869
|
+
return `#{value}.length === 0` if `Array.isArray(#{value})`
|
|
1870
|
+
false
|
|
1871
|
+
end
|
|
1872
|
+
|
|
1873
|
+
# Check if a value is present (not blank)
|
|
1874
|
+
# @param value [Object] Value to check
|
|
1875
|
+
# @return [Boolean] True if present
|
|
1876
|
+
def present?(value)
|
|
1877
|
+
!blank?(value)
|
|
1878
|
+
end
|
|
1879
|
+
|
|
1880
|
+
# Validate minimum length
|
|
1881
|
+
# @param value [String] Value to check
|
|
1882
|
+
# @param min [Integer] Minimum length
|
|
1883
|
+
# @return [Boolean] True if valid
|
|
1884
|
+
def min_length?(value, min)
|
|
1885
|
+
return false if value.nil?
|
|
1886
|
+
`#{value}.length >= #{min}`
|
|
1887
|
+
end
|
|
1888
|
+
|
|
1889
|
+
# Validate maximum length
|
|
1890
|
+
# @param value [String] Value to check
|
|
1891
|
+
# @param max [Integer] Maximum length
|
|
1892
|
+
# @return [Boolean] True if valid
|
|
1893
|
+
def max_length?(value, max)
|
|
1894
|
+
return false if value.nil?
|
|
1895
|
+
`#{value}.length <= #{max}`
|
|
1896
|
+
end
|
|
1897
|
+
|
|
1898
|
+
# Validate a value matches a pattern
|
|
1899
|
+
# @param value [String] Value to check
|
|
1900
|
+
# @param pattern [String] Regular expression pattern
|
|
1901
|
+
# @return [Boolean] True if matches
|
|
1902
|
+
def matches_pattern?(value, pattern)
|
|
1903
|
+
return false if value.nil?
|
|
1904
|
+
`new RegExp(#{pattern}).test(#{value})`
|
|
1905
|
+
end
|
|
1906
|
+
|
|
1907
|
+
# ===== Console Helpers =====
|
|
1908
|
+
|
|
1909
|
+
# Log a styled message to console
|
|
1910
|
+
# @param message [String] Message to log
|
|
1911
|
+
# @param style [String] CSS style string
|
|
1912
|
+
def console_styled(message, style = 'color: blue; font-weight: bold;')
|
|
1913
|
+
`console.log('%c' + #{message}, #{style})`
|
|
1914
|
+
end
|
|
1915
|
+
|
|
1916
|
+
# Log a grouped set of messages
|
|
1917
|
+
# @param label [String] Group label
|
|
1918
|
+
# @yield Block that logs messages
|
|
1919
|
+
def console_group(label, collapsed: false, &block)
|
|
1920
|
+
if collapsed
|
|
1921
|
+
`console.groupCollapsed(#{label})`
|
|
1922
|
+
else
|
|
1923
|
+
`console.group(#{label})`
|
|
1924
|
+
end
|
|
1925
|
+
block.call
|
|
1926
|
+
`console.groupEnd()`
|
|
1927
|
+
end
|
|
1928
|
+
|
|
1929
|
+
# Log a table
|
|
1930
|
+
# @param data [Array, Hash] Data to display as table
|
|
1931
|
+
def console_table(data)
|
|
1932
|
+
`console.table(#{data.to_n})`
|
|
1933
|
+
end
|
|
1934
|
+
|
|
1935
|
+
# Log with timestamp
|
|
1936
|
+
# @param message [String] Message to log
|
|
1937
|
+
def console_time(message)
|
|
1938
|
+
timestamp = `new Date().toISOString()`
|
|
1939
|
+
`console.log('[' + #{timestamp} + '] ' + #{message})`
|
|
1940
|
+
end
|
|
1941
|
+
|
|
1310
1942
|
private
|
|
1311
1943
|
|
|
1312
1944
|
# Convert snake_case to camelCase, preserving existing camelCase
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
# backtick_javascript: true
|
|
2
|
+
|
|
3
|
+
module OpalVite
|
|
4
|
+
module Concerns
|
|
5
|
+
module V1
|
|
6
|
+
# URIHelpers - provides URL parsing and manipulation utilities
|
|
7
|
+
#
|
|
8
|
+
# This module wraps JavaScript's URL and URLSearchParams APIs
|
|
9
|
+
# to provide Ruby-friendly methods for URL operations.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage
|
|
12
|
+
# class MyController < StimulusController
|
|
13
|
+
# include OpalVite::Concerns::V1::URIHelpers
|
|
14
|
+
#
|
|
15
|
+
# def connect
|
|
16
|
+
# url = parse_url("https://example.com/path?foo=bar")
|
|
17
|
+
# puts url_hostname(url) # => "example.com"
|
|
18
|
+
# puts url_param(url, "foo") # => "bar"
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
module URIHelpers
|
|
23
|
+
# ===== URL Parsing =====
|
|
24
|
+
|
|
25
|
+
# Parse a URL string into a URL object
|
|
26
|
+
# @param url_string [String] URL string to parse
|
|
27
|
+
# @return [Native] JavaScript URL object
|
|
28
|
+
def parse_url(url_string)
|
|
29
|
+
`new URL(#{url_string})`
|
|
30
|
+
rescue
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Parse a URL with a base URL
|
|
35
|
+
# @param url_string [String] Relative or absolute URL
|
|
36
|
+
# @param base [String] Base URL
|
|
37
|
+
# @return [Native] JavaScript URL object
|
|
38
|
+
def parse_url_with_base(url_string, base)
|
|
39
|
+
`new URL(#{url_string}, #{base})`
|
|
40
|
+
rescue
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get the current page URL
|
|
45
|
+
# @return [Native] JavaScript URL object
|
|
46
|
+
def current_url
|
|
47
|
+
`new URL(window.location.href)`
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ===== URL Components =====
|
|
51
|
+
|
|
52
|
+
# Get the protocol (scheme) of a URL
|
|
53
|
+
# @param url [Native] URL object
|
|
54
|
+
# @return [String] Protocol (e.g., "https:")
|
|
55
|
+
def url_protocol(url)
|
|
56
|
+
`#{url}.protocol`
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Get the hostname of a URL
|
|
60
|
+
# @param url [Native] URL object
|
|
61
|
+
# @return [String] Hostname (e.g., "example.com")
|
|
62
|
+
def url_hostname(url)
|
|
63
|
+
`#{url}.hostname`
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the host (hostname + port) of a URL
|
|
67
|
+
# @param url [Native] URL object
|
|
68
|
+
# @return [String] Host (e.g., "example.com:8080")
|
|
69
|
+
def url_host(url)
|
|
70
|
+
`#{url}.host`
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Get the port of a URL
|
|
74
|
+
# @param url [Native] URL object
|
|
75
|
+
# @return [String] Port number or empty string
|
|
76
|
+
def url_port(url)
|
|
77
|
+
`#{url}.port`
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Get the pathname of a URL
|
|
81
|
+
# @param url [Native] URL object
|
|
82
|
+
# @return [String] Pathname (e.g., "/path/to/page")
|
|
83
|
+
def url_pathname(url)
|
|
84
|
+
`#{url}.pathname`
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Get the search string (query string with ?)
|
|
88
|
+
# @param url [Native] URL object
|
|
89
|
+
# @return [String] Search string (e.g., "?foo=bar")
|
|
90
|
+
def url_search(url)
|
|
91
|
+
`#{url}.search`
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Get the hash (fragment) of a URL
|
|
95
|
+
# @param url [Native] URL object
|
|
96
|
+
# @return [String] Hash (e.g., "#section")
|
|
97
|
+
def url_hash(url)
|
|
98
|
+
`#{url}.hash`
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Get the origin of a URL
|
|
102
|
+
# @param url [Native] URL object
|
|
103
|
+
# @return [String] Origin (e.g., "https://example.com")
|
|
104
|
+
def url_origin(url)
|
|
105
|
+
`#{url}.origin`
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the full URL as a string
|
|
109
|
+
# @param url [Native] URL object
|
|
110
|
+
# @return [String] Full URL string
|
|
111
|
+
def url_to_string(url)
|
|
112
|
+
`#{url}.href`
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ===== Query Parameters =====
|
|
116
|
+
|
|
117
|
+
# Get a query parameter value
|
|
118
|
+
# @param url [Native] URL object
|
|
119
|
+
# @param name [String] Parameter name
|
|
120
|
+
# @return [String, nil] Parameter value or nil
|
|
121
|
+
def url_param(url, name)
|
|
122
|
+
result = `#{url}.searchParams.get(#{name})`
|
|
123
|
+
`#{result} === null` ? nil : result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Get all values for a query parameter
|
|
127
|
+
# @param url [Native] URL object
|
|
128
|
+
# @param name [String] Parameter name
|
|
129
|
+
# @return [Array<String>] Array of values
|
|
130
|
+
def url_params(url, name)
|
|
131
|
+
`Array.from(#{url}.searchParams.getAll(#{name}))`
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check if a query parameter exists
|
|
135
|
+
# @param url [Native] URL object
|
|
136
|
+
# @param name [String] Parameter name
|
|
137
|
+
# @return [Boolean] True if parameter exists
|
|
138
|
+
def url_has_param?(url, name)
|
|
139
|
+
`#{url}.searchParams.has(#{name})`
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get all query parameters as a Hash
|
|
143
|
+
# @param url [Native] URL object
|
|
144
|
+
# @return [Hash] Hash of parameter names to values
|
|
145
|
+
def url_all_params(url)
|
|
146
|
+
result = {}
|
|
147
|
+
`#{url}.searchParams.forEach((value, key) => {
|
|
148
|
+
#{result[`key`] = `value`}
|
|
149
|
+
})`
|
|
150
|
+
result
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Set a query parameter (mutates the URL object)
|
|
154
|
+
# @param url [Native] URL object
|
|
155
|
+
# @param name [String] Parameter name
|
|
156
|
+
# @param value [String] Parameter value
|
|
157
|
+
def url_set_param(url, name, value)
|
|
158
|
+
`#{url}.searchParams.set(#{name}, #{value})`
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Append a query parameter (allows duplicates)
|
|
162
|
+
# @param url [Native] URL object
|
|
163
|
+
# @param name [String] Parameter name
|
|
164
|
+
# @param value [String] Parameter value
|
|
165
|
+
def url_append_param(url, name, value)
|
|
166
|
+
`#{url}.searchParams.append(#{name}, #{value})`
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Delete a query parameter
|
|
170
|
+
# @param url [Native] URL object
|
|
171
|
+
# @param name [String] Parameter name
|
|
172
|
+
def url_delete_param(url, name)
|
|
173
|
+
`#{url}.searchParams.delete(#{name})`
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# ===== URL Building =====
|
|
177
|
+
|
|
178
|
+
# Build a URL from components
|
|
179
|
+
# @param options [Hash] URL components
|
|
180
|
+
# @option options [String] :protocol Protocol (default: "https:")
|
|
181
|
+
# @option options [String] :hostname Hostname (required)
|
|
182
|
+
# @option options [String] :port Port number
|
|
183
|
+
# @option options [String] :pathname Path
|
|
184
|
+
# @option options [Hash] :params Query parameters
|
|
185
|
+
# @option options [String] :hash Fragment
|
|
186
|
+
# @return [String] Built URL string
|
|
187
|
+
def build_url(options = {})
|
|
188
|
+
protocol = options[:protocol] || 'https:'
|
|
189
|
+
hostname = options[:hostname] || 'localhost'
|
|
190
|
+
port = options[:port]
|
|
191
|
+
pathname = options[:pathname] || '/'
|
|
192
|
+
params = options[:params] || {}
|
|
193
|
+
hash = options[:hash]
|
|
194
|
+
|
|
195
|
+
# Build base URL
|
|
196
|
+
base = "#{protocol}//#{hostname}"
|
|
197
|
+
base += ":#{port}" if port && !port.to_s.empty?
|
|
198
|
+
base += pathname
|
|
199
|
+
|
|
200
|
+
url = parse_url(base)
|
|
201
|
+
return nil unless url
|
|
202
|
+
|
|
203
|
+
# Add query parameters
|
|
204
|
+
params.each do |key, value|
|
|
205
|
+
url_set_param(url, key.to_s, value.to_s)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Add hash
|
|
209
|
+
`#{url}.hash = #{hash}` if hash
|
|
210
|
+
|
|
211
|
+
url_to_string(url)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# ===== URL Encoding =====
|
|
215
|
+
|
|
216
|
+
# Encode a URI component
|
|
217
|
+
# @param str [String] String to encode
|
|
218
|
+
# @return [String] Encoded string
|
|
219
|
+
def encode_uri_component(str)
|
|
220
|
+
`encodeURIComponent(#{str})`
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Decode a URI component
|
|
224
|
+
# @param str [String] String to decode
|
|
225
|
+
# @return [String] Decoded string
|
|
226
|
+
def decode_uri_component(str)
|
|
227
|
+
`decodeURIComponent(#{str})`
|
|
228
|
+
rescue
|
|
229
|
+
str
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Encode a full URI
|
|
233
|
+
# @param str [String] URI to encode
|
|
234
|
+
# @return [String] Encoded URI
|
|
235
|
+
def encode_uri(str)
|
|
236
|
+
`encodeURI(#{str})`
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Decode a full URI
|
|
240
|
+
# @param str [String] URI to decode
|
|
241
|
+
# @return [String] Decoded URI
|
|
242
|
+
def decode_uri(str)
|
|
243
|
+
`decodeURI(#{str})`
|
|
244
|
+
rescue
|
|
245
|
+
str
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# ===== Query String Utilities =====
|
|
249
|
+
|
|
250
|
+
# Parse a query string into a Hash
|
|
251
|
+
# @param query_string [String] Query string (with or without leading ?)
|
|
252
|
+
# @return [Hash] Parsed parameters
|
|
253
|
+
def parse_query_string(query_string)
|
|
254
|
+
# Remove leading ? if present
|
|
255
|
+
qs = query_string.to_s
|
|
256
|
+
qs = qs[1..-1] if qs.start_with?('?')
|
|
257
|
+
|
|
258
|
+
result = {}
|
|
259
|
+
params = `new URLSearchParams(#{qs})`
|
|
260
|
+
`#{params}.forEach((value, key) => {
|
|
261
|
+
#{result[`key`] = `value`}
|
|
262
|
+
})`
|
|
263
|
+
result
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Build a query string from a Hash
|
|
267
|
+
# @param params [Hash] Parameters
|
|
268
|
+
# @return [String] Query string (without leading ?)
|
|
269
|
+
def build_query_string(params)
|
|
270
|
+
search_params = `new URLSearchParams()`
|
|
271
|
+
params.each do |key, value|
|
|
272
|
+
`#{search_params}.append(#{key.to_s}, #{value.to_s})`
|
|
273
|
+
end
|
|
274
|
+
`#{search_params}.toString()`
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# ===== Path Utilities =====
|
|
278
|
+
|
|
279
|
+
# Join path segments
|
|
280
|
+
# @param segments [Array<String>] Path segments
|
|
281
|
+
# @return [String] Joined path
|
|
282
|
+
def join_path(*segments)
|
|
283
|
+
segments.map { |s| s.to_s.gsub(%r{^/|/$}, '') }.reject(&:empty?).join('/')
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Get the filename from a path
|
|
287
|
+
# @param path [String] Path
|
|
288
|
+
# @return [String] Filename
|
|
289
|
+
def path_basename(path)
|
|
290
|
+
path.to_s.split('/').last || ''
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Get the directory from a path
|
|
294
|
+
# @param path [String] Path
|
|
295
|
+
# @return [String] Directory path
|
|
296
|
+
def path_dirname(path)
|
|
297
|
+
parts = path.to_s.split('/')
|
|
298
|
+
parts.pop
|
|
299
|
+
parts.join('/') || '/'
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Get the file extension from a path
|
|
303
|
+
# @param path [String] Path
|
|
304
|
+
# @return [String] Extension (with dot) or empty string
|
|
305
|
+
def path_extname(path)
|
|
306
|
+
basename = path_basename(path)
|
|
307
|
+
idx = basename.rindex('.')
|
|
308
|
+
idx ? basename[idx..-1] : ''
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Alias for convenience
|
|
316
|
+
URIHelpers = OpalVite::Concerns::V1::URIHelpers
|
|
@@ -6,3 +6,5 @@ require 'opal_vite/concerns/v1/storable'
|
|
|
6
6
|
require 'opal_vite/concerns/v1/stimulus_helpers'
|
|
7
7
|
require 'opal_vite/concerns/v1/vue_helpers'
|
|
8
8
|
require 'opal_vite/concerns/v1/react_helpers'
|
|
9
|
+
require 'opal_vite/concerns/v1/uri_helpers'
|
|
10
|
+
require 'opal_vite/concerns/v1/base64_helpers'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: opal-vite
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- stofu1234
|
|
@@ -120,12 +120,14 @@ files:
|
|
|
120
120
|
- opal/opal_vite/concerns/storable.rb
|
|
121
121
|
- opal/opal_vite/concerns/toastable.rb
|
|
122
122
|
- opal/opal_vite/concerns/v1.rb
|
|
123
|
+
- opal/opal_vite/concerns/v1/base64_helpers.rb
|
|
123
124
|
- opal/opal_vite/concerns/v1/dom_helpers.rb
|
|
124
125
|
- opal/opal_vite/concerns/v1/js_proxy_ex.rb
|
|
125
126
|
- opal/opal_vite/concerns/v1/react_helpers.rb
|
|
126
127
|
- opal/opal_vite/concerns/v1/stimulus_helpers.rb
|
|
127
128
|
- opal/opal_vite/concerns/v1/storable.rb
|
|
128
129
|
- opal/opal_vite/concerns/v1/toastable.rb
|
|
130
|
+
- opal/opal_vite/concerns/v1/uri_helpers.rb
|
|
129
131
|
- opal/opal_vite/concerns/v1/vue_helpers.rb
|
|
130
132
|
- opal/opal_vite/concerns/vue_helpers.rb
|
|
131
133
|
homepage: https://stofu1234.github.io/opal-vite/
|