opal-vite 0.3.19 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ecdd0963648ad4f8e23a05841df5bb7f3b8205ad18c54c5a9b194ae84ae8996d
4
- data.tar.gz: 802febb7fa45d581b5e96223a3922b980190b430a513688ad02cf57736ffa253
3
+ metadata.gz: ba1cd330c7e4a9de6c8a3cefac5bebd1e4f61187708babc92bb15ea5bcffa896
4
+ data.tar.gz: dfd2b6ec75acfd368e36b66e1fc88628b047b90a008ac831991f9ef5f2a07bd1
5
5
  SHA512:
6
- metadata.gz: 918e5f96eac1e25c4b2f93a120262b5c2aa8a1c4dab8d725fd76b0d2357227a251a09dd4f889838ce11f1ad3c71e48406ce54e52f509192d826a9cf9f31583a7
7
- data.tar.gz: 1634578d7ee98ce77f86d87d897491b84225aa6e77aeb2a252b25a3e3cfdce11899d095d6a14ba91a7f10fbaf45d07f062104712e971c92cf9440aab1dfe3741
6
+ metadata.gz: d14e7be8d32dbf925fb7fd1c5f95272272cde0f3ee2ab027baff615d1f1ed80b01ef3d2ef1f3baa59c85060693ab44e5cf3de078316b1d9176a8cec9318397a8
7
+ data.tar.gz: d67ba35d806db20dee767796b82a7f4c1d68361404817665b6d17c4d3583e4c035463f97697932212fed583b164a457fc5c988f939db22fff4f6f3b02d2fa1fe
data/lib/opal/vite/cli.rb CHANGED
@@ -29,6 +29,8 @@ module Opal
29
29
  private
30
30
 
31
31
  def compile_command
32
+ # Parse options first so they may appear before or after the file name
33
+ parse_compile_options
32
34
  file_path = @argv.shift
33
35
 
34
36
  unless file_path
@@ -42,12 +44,16 @@ module Opal
42
44
  exit 1
43
45
  end
44
46
 
45
- parse_compile_options
47
+ # Without -o the compiled JS goes to stdout, so progress messages go to
48
+ # stderr to keep stdout a clean JS stream
49
+ log = @options[:output] ? $stdout : $stderr
46
50
 
47
51
  begin
48
- puts "Compiling #{file_path}..."
52
+ log.puts "Compiling #{file_path}..."
49
53
 
50
- compiler = Opal::Vite::Compiler.new
54
+ config = Opal::Vite::Config.new
55
+ config.source_map_enabled = !!@options[:source_map]
56
+ compiler = Opal::Vite::Compiler.new(config: config)
51
57
  source = File.read(file_path)
52
58
  result = compiler.compile(source, file_path)
53
59
 
@@ -66,13 +72,13 @@ module Opal
66
72
  end
67
73
 
68
74
  if @options[:verbose]
69
- puts "\nDependencies:"
75
+ log.puts "\nDependencies:"
70
76
  result[:dependencies].each do |dep|
71
- puts " - #{dep}"
77
+ log.puts " - #{dep}"
72
78
  end
73
79
  end
74
80
 
75
- puts "✅ Compilation successful!"
81
+ log.puts "✅ Compilation successful!"
76
82
  rescue => e
77
83
  puts "❌ Compilation failed: #{e.message}"
78
84
  puts e.backtrace.first(5).join("\n") if @options[:verbose]
@@ -133,6 +139,9 @@ module Opal
133
139
  @options[:verbose] = true
134
140
  end
135
141
  end.parse!(@argv)
142
+ rescue OptionParser::ParseError => e
143
+ puts "Error: #{e.message}"
144
+ exit 1
136
145
  end
137
146
  end
138
147
  end
@@ -22,12 +22,18 @@ module Opal
22
22
  @compiler_options = options.fetch(:compiler_options, {})
23
23
  end
24
24
 
25
+ # Opal compiler options for the Builder. Options explicitly set through
26
+ # Opal::Vite.configure come first; options given to Compiler.new win.
27
+ def builder_compiler_options
28
+ @config.explicit_compiler_options.merge(@compiler_options)
29
+ end
30
+
25
31
  # Compile Ruby source code to JavaScript
26
32
  # Returns a hash with :code, :map, and :dependencies
27
33
  def compile(source, file_path)
28
34
  begin
29
35
  # Use Opal::Builder and add the file's directory to load paths
30
- builder = Opal::Builder.new(stubs: @stubs, compiler_options: @compiler_options)
36
+ builder = Opal::Builder.new(stubs: @stubs, compiler_options: builder_compiler_options)
31
37
  builder.prerequired = self.class.runtime_requires if @external_runtime
32
38
 
33
39
  # Add the directory containing the file to load paths
@@ -65,7 +71,8 @@ module Opal
65
71
  end
66
72
 
67
73
  result
68
- rescue StandardError => e
74
+ rescue StandardError, ScriptError => e
75
+ # ScriptError covers LoadError / Opal::MissingRequire and syntax errors
69
76
  raise CompilationError, "Failed to compile #{file_path}: #{e.message}\n#{e.backtrace.first(5).join("\n")}"
70
77
  end
71
78
  end
@@ -323,6 +330,7 @@ module Opal
323
330
  section_abs_name = 0
324
331
 
325
332
  first_segment_processed = false
333
+ name_bridged = false
326
334
 
327
335
  lines.each do |line|
328
336
  if line.empty?
@@ -364,12 +372,21 @@ module Opal
364
372
  new_name_delta = global_abs_name - prev_name
365
373
 
366
374
  new_values = [gen_col_delta, new_source_delta, new_orig_line_delta, new_orig_col_delta]
367
- new_values << new_name_delta if values.length > 4
375
+ if values.length > 4
376
+ new_values << new_name_delta
377
+ name_bridged = true
378
+ end
368
379
 
369
380
  first_segment_processed = true
370
381
  else
371
382
  # Subsequent segments: deltas are already correct (relative within section = relative within merged)
372
383
  new_values = values.dup
384
+ if values.length > 4 && !name_bridged
385
+ # The first segment of this section had no name, so this is
386
+ # the section's first name: bridge from the merged name state
387
+ new_values[4] = global_abs_name - prev_name
388
+ name_bridged = true
389
+ end
373
390
  end
374
391
 
375
392
  result_segments << encode_vlq(new_values)
@@ -3,14 +3,22 @@ require 'json'
3
3
  module Opal
4
4
  module Vite
5
5
  class Config
6
- attr_accessor :source_map_enabled,
7
- :arity_check,
8
- :freezing,
9
- :esm,
10
- :dynamic_require_severity,
11
- :missing_require_severity
6
+ # Options passed on to Opal's compiler (see #explicit_compiler_options)
7
+ COMPILER_OPTION_KEYS = %i[arity_check freezing esm dynamic_require_severity missing_require_severity].freeze
8
+
9
+ attr_accessor :source_map_enabled
10
+
11
+ attr_reader(*COMPILER_OPTION_KEYS)
12
+
13
+ COMPILER_OPTION_KEYS.each do |key|
14
+ define_method(:"#{key}=") do |value|
15
+ @explicit_compiler_options[key] = value
16
+ instance_variable_set(:"@#{key}", value)
17
+ end
18
+ end
12
19
 
13
20
  def initialize
21
+ @explicit_compiler_options = {}
14
22
  @source_map_enabled = true
15
23
  @arity_check = false
16
24
  @freezing = true
@@ -35,6 +43,16 @@ module Opal
35
43
  end
36
44
  end
37
45
 
46
+ # Compiler options that were set explicitly (via a writer, a config file
47
+ # or Opal::Vite.configure). Unset ones are left to Opal's own defaults, so
48
+ # the output does not change unless the user asked for it.
49
+ def explicit_compiler_options
50
+ @explicit_compiler_options.to_h do |key, value|
51
+ # Severities arrive as strings when read from a JSON config file
52
+ [key, key.to_s.end_with?('_severity') && value ? value.to_sym : value]
53
+ end
54
+ end
55
+
38
56
  def to_compiler_options
39
57
  {
40
58
  source_map_enabled: source_map_enabled,
@@ -81,8 +81,9 @@ module Opal
81
81
  # @param value [String] Value to set
82
82
  # @param timeout [Integer] Maximum wait time in seconds
83
83
  def stable_set(selector, value, timeout: DEFAULT_TIMEOUT)
84
- escaped_value = escape_js(value)
85
- js_retry_action(selector, 'set', value: escaped_value, timeout: timeout)
84
+ # js_retry_action escapes the value itself; escaping here too would
85
+ # double-escape quotes and backslashes
86
+ js_retry_action(selector, 'set', value: value.to_s, timeout: timeout)
86
87
  end
87
88
 
88
89
  # Send keys to element with stability check
@@ -360,7 +361,7 @@ module Opal
360
361
  # @param timeout [Integer] Maximum wait time in seconds
361
362
  def js_retry_action(selector, action, value: nil, timeout: DEFAULT_TIMEOUT)
362
363
  escaped_selector = escape_js(selector)
363
- escaped_value = value ? escape_js(value) : ''
364
+ escaped_value = value.nil? ? '' : escape_js(value)
364
365
  start_time = Time.now
365
366
 
366
367
  loop do
@@ -404,7 +405,7 @@ module Opal
404
405
 
405
406
  # Escape string for JavaScript
406
407
  def escape_js(str)
407
- str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub("\n", '\\n')
408
+ str.to_s.gsub('\\', '\\\\\\\\').gsub("'", "\\\\'").gsub("\n", '\\n').gsub("\r", '\\r')
408
409
  end
409
410
  end
410
411
  end
@@ -1,5 +1,5 @@
1
1
  module Opal
2
2
  module Vite
3
- VERSION = "0.3.19"
3
+ VERSION = "0.4.0"
4
4
  end
5
5
  end
@@ -88,6 +88,9 @@ module OpalVite
88
88
  raise "Cable not connected. Call cable_connect first." unless @_cable_consumer
89
89
 
90
90
  @_cable_subscriptions ||= {}
91
+ # Subscribing again with the same channel and params replaces the
92
+ # earlier subscription instead of leaving both active
93
+ unsubscribe_from(channel_name, params)
91
94
  subscription_params = { channel: channel_name }.merge(params)
92
95
  native_params = subscription_params.to_n
93
96
 
@@ -204,6 +207,9 @@ module OpalVite
204
207
  raise "Cable not connected. Call cable_connect first." unless @_cable_consumer
205
208
 
206
209
  @_cable_subscriptions ||= {}
210
+ # Subscribing again with the same channel and params replaces the
211
+ # earlier subscription instead of leaving both active
212
+ unsubscribe_from(channel_name, params)
207
213
  subscription_params = { channel: channel_name }.merge(params)
208
214
  native_params = subscription_params.to_n
209
215
 
@@ -354,7 +360,8 @@ module OpalVite
354
360
  def cable_route(data, handlers, type_key = "type")
355
361
  data_type = cable_data_type(data, type_key)
356
362
  return unless data_type
357
- handler = handlers[data_type] || handlers[data_type.to_sym]
363
+ # The type may be a non-String (e.g. a number), so convert before to_sym
364
+ handler = handlers[data_type] || handlers[data_type.to_s] || handlers[data_type.to_s.to_sym]
358
365
  handler&.call
359
366
  end
360
367
 
@@ -164,7 +164,8 @@ module OpalVite
164
164
  # @return [String] Basic auth header value
165
165
  def basic_auth_header(username, password)
166
166
  credentials = "#{username}:#{password}"
167
- encoded = base64_encode(credentials)
167
+ # UTF-8 (RFC 7617), so non-Latin-1 characters work; btoa alone fails on them
168
+ encoded = base64_encode_unicode(credentials)
168
169
  "Basic #{encoded}"
169
170
  end
170
171
 
@@ -177,6 +178,7 @@ module OpalVite
177
178
  encoded = header[6..-1]
178
179
  decoded = base64_decode(encoded)
179
180
  return nil unless decoded
181
+ decoded = binary_to_utf8(decoded)
180
182
 
181
183
  parts = decoded.split(':', 2)
182
184
  return nil if parts.length != 2
@@ -200,6 +202,7 @@ module OpalVite
200
202
  payload_base64 = parts[1]
201
203
  payload_json = base64_decode_urlsafe(payload_base64)
202
204
  return nil unless payload_json
205
+ payload_json = binary_to_utf8(payload_json)
203
206
 
204
207
  `JSON.parse(#{payload_json})`
205
208
  rescue Exception
@@ -268,6 +271,14 @@ module OpalVite
268
271
  # unpadded/URL-safe input (len % 4 == 2 or 3) would yield e.g. 16.5.
269
272
  ((len * 3) / 4).floor - padding
270
273
  end
274
+
275
+ private
276
+
277
+ # Reinterpret a binary string (as returned by atob) as UTF-8 text.
278
+ # Input that is not valid UTF-8 is returned unchanged.
279
+ def binary_to_utf8(str)
280
+ `(function(s) { try { return decodeURIComponent(escape(s)); } catch (e) { return s; } })(#{str})`
281
+ end
271
282
  end
272
283
  end
273
284
  end
@@ -367,12 +367,14 @@ module OpalVite
367
367
 
368
368
  # Get JSON-parsed value from localStorage
369
369
  # @param key [String] Storage key
370
- # @param default [Object] Default value if key doesn't exist
370
+ # @param default [Object] Default value if key doesn't exist or is not valid JSON
371
371
  # @return [Object] Parsed value or default
372
372
  def storage_get_json(key, default = nil)
373
373
  stored = storage_get(key)
374
374
  return default if `#{stored} === null`
375
- `JSON.parse(#{stored})`
375
+ # A value that is not valid JSON (e.g. written by something else)
376
+ # yields the default instead of raising
377
+ `(function(s, d) { try { return JSON.parse(s); } catch (e) { return d; } })(#{stored}, #{default})`
376
378
  end
377
379
 
378
380
  # Set JSON-stringified value in localStorage
@@ -457,34 +459,41 @@ module OpalVite
457
459
  end
458
460
 
459
461
  # Get the current event's target element
462
+ # @param evt [Native, nil] The event (the argument Stimulus passes to the
463
+ # action). Pass it when you can: without it the deprecated global
464
+ # window.event is used, which is undefined after an await / in callbacks.
460
465
  # @return [Native] Event target element
461
- def event_target
462
- `event.currentTarget`
466
+ def event_target(evt: nil)
467
+ e = current_event(evt)
468
+ `#{e}.currentTarget`
463
469
  end
464
470
 
465
471
  # Get data attribute from current event target
466
472
  # @param attr [String] Data attribute name (without 'data-' prefix)
467
473
  # @return [String, nil] Attribute value
468
- def event_data(attr)
469
- `event.currentTarget.getAttribute('data-' + #{attr})`
474
+ def event_data(attr, evt: nil)
475
+ e = current_event(evt)
476
+ `#{e}.currentTarget.getAttribute('data-' + #{attr})`
470
477
  end
471
478
 
472
479
  # Get integer data attribute from current event target
473
480
  # @param attr [String] Data attribute name (without 'data-' prefix)
474
481
  # @return [Integer, nil] Parsed integer value
475
- def event_data_int(attr)
476
- parse_int(event_data(attr))
482
+ def event_data_int(attr, evt: nil)
483
+ parse_int(event_data(attr, evt: evt))
477
484
  end
478
485
 
479
486
  # Prevent default event behavior
480
- def prevent_default
481
- `event.preventDefault()`
487
+ def prevent_default(evt: nil)
488
+ e = current_event(evt)
489
+ `#{e}.preventDefault()`
482
490
  end
483
491
 
484
492
  # Get event key (for keyboard events)
485
493
  # @return [String] Key name
486
- def event_key
487
- `event.key`
494
+ def event_key(evt: nil)
495
+ e = current_event(evt)
496
+ `#{e}.key`
488
497
  end
489
498
 
490
499
  # ===== Element Methods =====
@@ -1585,8 +1594,9 @@ module OpalVite
1585
1594
  # def delete(event)
1586
1595
  # params = action_params # => { id: 123 }
1587
1596
  # end
1588
- def action_params
1589
- `event.params || {}`
1597
+ def action_params(evt: nil)
1598
+ e = current_event(evt)
1599
+ `#{e}.params || {}`
1590
1600
  end
1591
1601
 
1592
1602
  # Get a specific action parameter
@@ -1595,16 +1605,17 @@ module OpalVite
1595
1605
  # @example
1596
1606
  # action_param(:id) # => 123 (Number)
1597
1607
  # action_param(:url) # => "/api/item" (String)
1598
- def action_param(name)
1599
- `(event.params || {})[#{name.to_s}]`
1608
+ def action_param(name, evt: nil)
1609
+ e = current_event(evt)
1610
+ `(#{e}.params || {})[#{name.to_s}]`
1600
1611
  end
1601
1612
 
1602
1613
  # Get action parameter as integer with default
1603
1614
  # @param name [Symbol, String] Parameter name
1604
1615
  # @param default [Integer] Default value if missing or NaN
1605
1616
  # @return [Integer] Parameter value
1606
- def action_param_int(name, default = 0)
1607
- value = action_param(name)
1617
+ def action_param_int(name, default = 0, evt: nil)
1618
+ value = action_param(name, evt: evt)
1608
1619
  result = parse_int(value)
1609
1620
  is_nan?(result) ? default : result
1610
1621
  end
@@ -1612,16 +1623,17 @@ module OpalVite
1612
1623
  # Get action parameter as boolean
1613
1624
  # @param name [Symbol, String] Parameter name
1614
1625
  # @return [Boolean] Parameter value
1615
- def action_param_bool(name)
1616
- value = action_param(name)
1626
+ def action_param_bool(name, evt: nil)
1627
+ value = action_param(name, evt: evt)
1617
1628
  `!!#{value} && #{value} !== "false" && #{value} !== "0"`
1618
1629
  end
1619
1630
 
1620
1631
  # Check if an action parameter exists
1621
1632
  # @param name [Symbol, String] Parameter name
1622
1633
  # @return [Boolean] true if parameter exists
1623
- def has_action_param?(name)
1624
- `(event.params || {}).hasOwnProperty(#{name.to_s})`
1634
+ def has_action_param?(name, evt: nil)
1635
+ e = current_event(evt)
1636
+ `(#{e}.params || {}).hasOwnProperty(#{name.to_s})`
1625
1637
  end
1626
1638
 
1627
1639
  # ===== Stimulus Controller Access =====
@@ -1777,13 +1789,15 @@ module OpalVite
1777
1789
  # Deep merge two objects
1778
1790
  # @param target [Hash] Target object
1779
1791
  # @param source [Hash] Source object
1780
- # @return [Hash] Merged object
1792
+ # @return [Native] Merged plain JavaScript object (not a Ruby Hash);
1793
+ # the keys __proto__ / constructor / prototype are skipped
1781
1794
  def deep_merge(target, source)
1782
1795
  `
1783
1796
  function deepMerge(target, source) {
1784
1797
  var result = Object.assign({}, target);
1785
1798
  for (var key in source) {
1786
- if (source.hasOwnProperty(key)) {
1799
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue;
1800
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
1787
1801
  if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
1788
1802
  result[key] = deepMerge(result[key] || {}, source[key]);
1789
1803
  } else {
@@ -1991,6 +2005,13 @@ module OpalVite
1991
2005
 
1992
2006
  private
1993
2007
 
2008
+ # The event to read: the one passed in (a wrapper such as opal_stimulus's
2009
+ # JS::Proxy or Native is unwrapped with to_n), else the global window.event
2010
+ # (deprecated, and undefined outside the synchronous event dispatch).
2011
+ def current_event(evt)
2012
+ `(function(e) { if (e == null || e === #{nil}) return window.event; return typeof e.$to_n === 'function' ? e.$to_n() : e; })(#{evt})`
2013
+ end
2014
+
1994
2015
  # Convert snake_case to camelCase, preserving existing camelCase
1995
2016
  # @param name [Symbol, String] The name to convert
1996
2017
  # @param capitalize_first [Boolean] Whether to capitalize first letter
@@ -159,15 +159,18 @@ module OpalVite
159
159
 
160
160
  # Get the target frame from current event
161
161
  # @return [Native, nil] Frame element from event
162
- def event_turbo_frame
163
- `event.target.closest('turbo-frame')`
162
+ # @param evt [Native, nil] The event; defaults to the deprecated global window.event
163
+ def event_turbo_frame(evt: nil)
164
+ e = `(function(e) { if (e == null || e === #{nil}) return window.event; return typeof e.$to_n === 'function' ? e.$to_n() : e; })(#{evt})`
165
+ `#{e}.target.closest('turbo-frame')`
164
166
  end
165
167
 
166
168
  # ===== Turbo Streams =====
167
169
 
168
170
  # Render a Turbo Stream action
169
171
  # @param action [Symbol, String] Stream action (:append, :prepend, :replace, :update, :remove, :before, :after)
170
- # @param target [String] Target element ID
172
+ # @param target [String] Target element ID (HTML-escaped when rendered;
173
+ # the html argument is NOT escaped, escape user data with escape_html)
171
174
  # @param html [String] HTML content (not needed for :remove)
172
175
  # @example
173
176
  # turbo_stream(:append, "messages", "<div>New message</div>")
@@ -176,7 +179,7 @@ module OpalVite
176
179
  action_s = action.to_s
177
180
  template_content = html ? "<template>#{html}</template>" : ""
178
181
 
179
- stream_html = %{<turbo-stream action="#{action_s}" target="#{target}">#{template_content}</turbo-stream>}
182
+ stream_html = %{<turbo-stream action="#{turbo_escape_attr(action_s)}" target="#{turbo_escape_attr(target)}">#{template_content}</turbo-stream>}
180
183
  render_turbo_stream(stream_html)
181
184
  end
182
185
 
@@ -468,6 +471,11 @@ module OpalVite
468
471
 
469
472
  private
470
473
 
474
+ # Escape a value for use inside a double-quoted HTML attribute
475
+ def turbo_escape_attr(value)
476
+ TurboStreamBuilder.escape_attr(value)
477
+ end
478
+
471
479
  def turbo_event_name(event_name)
472
480
  event_name.start_with?("turbo:") ? event_name : "turbo:#{event_name}"
473
481
  end
@@ -475,36 +483,42 @@ module OpalVite
475
483
 
476
484
  # Builder for creating multiple Turbo Stream operations
477
485
  class TurboStreamBuilder
486
+ # Escape a value for use inside a double-quoted HTML attribute
487
+ def self.escape_attr(value)
488
+ str = value.to_s
489
+ `#{str}.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;')`
490
+ end
491
+
478
492
  def initialize
479
493
  @streams = []
480
494
  end
481
495
 
482
496
  def append(target, html)
483
- @streams << %{<turbo-stream action="append" target="#{target}"><template>#{html}</template></turbo-stream>}
497
+ @streams << %{<turbo-stream action="append" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
484
498
  end
485
499
 
486
500
  def prepend(target, html)
487
- @streams << %{<turbo-stream action="prepend" target="#{target}"><template>#{html}</template></turbo-stream>}
501
+ @streams << %{<turbo-stream action="prepend" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
488
502
  end
489
503
 
490
504
  def replace(target, html)
491
- @streams << %{<turbo-stream action="replace" target="#{target}"><template>#{html}</template></turbo-stream>}
505
+ @streams << %{<turbo-stream action="replace" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
492
506
  end
493
507
 
494
508
  def update(target, html)
495
- @streams << %{<turbo-stream action="update" target="#{target}"><template>#{html}</template></turbo-stream>}
509
+ @streams << %{<turbo-stream action="update" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
496
510
  end
497
511
 
498
512
  def remove(target)
499
- @streams << %{<turbo-stream action="remove" target="#{target}"></turbo-stream>}
513
+ @streams << %{<turbo-stream action="remove" target="#{TurboStreamBuilder.escape_attr(target)}"></turbo-stream>}
500
514
  end
501
515
 
502
516
  def before(target, html)
503
- @streams << %{<turbo-stream action="before" target="#{target}"><template>#{html}</template></turbo-stream>}
517
+ @streams << %{<turbo-stream action="before" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
504
518
  end
505
519
 
506
520
  def after(target, html)
507
- @streams << %{<turbo-stream action="after" target="#{target}"><template>#{html}</template></turbo-stream>}
521
+ @streams << %{<turbo-stream action="after" target="#{TurboStreamBuilder.escape_attr(target)}"><template>#{html}</template></turbo-stream>}
508
522
  end
509
523
 
510
524
  def render
@@ -277,10 +277,12 @@ module OpalVite
277
277
  # ===== Path Utilities =====
278
278
 
279
279
  # Join path segments
280
- # @param segments [Array<String>] Path segments
280
+ # @param segments [Array<String>] Path segments (a leading "/" on the first one is kept)
281
281
  # @return [String] Joined path
282
282
  def join_path(*segments)
283
- segments.map { |s| s.to_s.gsub(%r{^/|/$}, '') }.reject(&:empty?).join('/')
283
+ joined = segments.map { |s| s.to_s.gsub(%r{^/+|/+$}, '') }.reject(&:empty?).join('/')
284
+ # Keep the leading slash of an absolute first segment (join_path('/api', 'users') => '/api/users')
285
+ segments.first.to_s.start_with?('/') ? "/#{joined}" : joined
284
286
  end
285
287
 
286
288
  # Get the filename from a path
@@ -294,9 +296,11 @@ module OpalVite
294
296
  # @param path [String] Path
295
297
  # @return [String] Directory path
296
298
  def path_dirname(path)
297
- parts = path.to_s.split('/')
298
- parts.pop
299
- parts.join('/') || '/'
299
+ str = path.to_s.sub(%r{/+$}, '')
300
+ idx = str.rindex('/')
301
+ return '' if idx.nil? # no directory part
302
+ return '/' if idx == 0 # '/file.txt' => '/'
303
+ str[0...idx]
300
304
  end
301
305
 
302
306
  # Get the file extension from a path
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.3.19
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - stofu1234