opal_proxy 0.1.1 → 0.2.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: 1398d427665fc0e194ff83e2d460400d42bc44302332d823723cd1b6c33d7182
4
- data.tar.gz: ae36c54be48b23bab603f5238356835e65c7b3fc7e3534ad64608a79f5c40c97
3
+ metadata.gz: 16f6f216939bb7ad43a3e8046eba3a468376b385b7c517e7f114ba3ed4fb6f8e
4
+ data.tar.gz: 2fd8b4d5b95354facae44f4978a2b8ed566ff70d005269d2ab715ab1e60a69f6
5
5
  SHA512:
6
- metadata.gz: 81c9493e8dfb5fafa896ac9f0184b4d98a0b37813bcd1b538d7e3b9521a5ffabc213e5c58793199438d0a31d23fcb682cd3d4fb8d22606f9e1a1d4ad258e1c6b
7
- data.tar.gz: 3ab3b1320f3956d54ac30baf2b2784e2529a3691bcb11b8c68a90f11aa83e0b52179b1c009711a00de57164ca447a4c83def16885930ef56336549d72a7c7056
6
+ metadata.gz: 378e55ea22b7d67177c22cf580e4adf00fc92d9d76d67c3ee531fdc8a462a2471b12625639d8c7a82d213a6960b96e4a0f86511b5a5df2c733edd0da3416dbad
7
+ data.tar.gz: f4520e248767de0a95bb026eba5adadf06e0b0473626436421ff27759a73a4c455862a640535ccef3b0a62cb4b9b54d9108fbadb00b2907a689e2eea19e8c6cf
data/.rubocop.yml CHANGED
@@ -1,6 +1,42 @@
1
1
  AllCops:
2
+ NewCops: enable
3
+ SuggestExtensions: false
2
4
  TargetRubyVersion: 3.1
3
5
 
6
+ Metrics/ClassLength:
7
+ Exclude:
8
+ - "test/**/*.rb"
9
+ Max: 150
10
+
11
+ Metrics/MethodLength:
12
+ Exclude:
13
+ - "lib/js/**/*.rb"
14
+ - "test/**/*.rb"
15
+
16
+ Lint/LiteralAsCondition:
17
+ Exclude:
18
+ - "lib/js/**/*.rb"
19
+
20
+ Lint/UnusedMethodArgument:
21
+ Exclude:
22
+ - "lib/js/**/*.rb"
23
+
24
+ Lint/UselessAssignment:
25
+ Exclude:
26
+ - "lib/js/**/*.rb"
27
+
28
+ Lint/Void:
29
+ Exclude:
30
+ - "lib/js/**/*.rb"
31
+
32
+ Naming/PredicateMethod:
33
+ Exclude:
34
+ - "lib/js/**/*.rb"
35
+
36
+ Style/CommandLiteral:
37
+ Exclude:
38
+ - "lib/js/**/*.rb"
39
+
4
40
  Style/StringLiterals:
5
41
  EnforcedStyle: double_quotes
6
42
 
data/CHANGELOG.md CHANGED
@@ -1,9 +1,19 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2025-07-24
3
+ ## [0.2.0] - 2026-08-19
4
+
5
+ - Preserve `JS::Promise` wrappers across `then` and `catch` chains.
6
+ - Wrap callback receivers and arguments, and unwrap callback return values.
7
+ - Support JavaScript iterables and array-like objects through `Enumerable`.
8
+ - Make `respond_to?` reflect native property availability.
9
+ - Improve exact, camelCase, and acronym-aware property lookup.
10
+ - Refresh native property introspection for dynamically added properties.
11
+ - Add deterministic browser coverage without external network dependencies.
4
12
 
5
- - Initial release
13
+ ## [0.1.1] - 2025-08-08
6
14
 
7
- ## [0.1.0] - 2025-08-08
15
+ - Add `[]=` method to Proxy.
16
+
17
+ ## [0.1.0] - 2025-07-24
8
18
 
9
- - Add `[]=` method to Proxy
19
+ - Initial release.
data/README.md CHANGED
@@ -58,14 +58,44 @@ window.set_timeout(-> {
58
58
  puts "1. Timeout test OK (1s delay)"
59
59
  }, 1000)
60
60
  window.fetch("https://jsonplaceholder.typicode.com/todos/1")
61
- .then do |response|
62
- response.json().then do |data|
63
- puts "5. Fetched: #{data["title"]}"
64
- document.get_element_by_id("output").inner_html += "<p>5. Fetched: #{data["title"]}</p>"
65
- end
61
+ .then { |response| response.json }
62
+ .then do |data|
63
+ puts "5. Fetched: #{data["title"]}"
64
+ document.get_element_by_id("output").inner_html += "<p>5. Fetched: #{data["title"]}</p>"
66
65
  end
66
+ .catch { |error| warn error["message"] }
67
+ ```
68
+
69
+ Promise callbacks automatically unwrap returned `JS::Proxy` and `JS::Promise` values,
70
+ so native Promise flattening works across chained `then`, `catch`, and `finally` calls.
71
+
72
+ ## Interop behavior
73
+
74
+ Property lookup tries the original name first, then camelCase and common JavaScript
75
+ acronyms such as `HTML`, `URL`, and `URI`. For example, both `inner_html` and
76
+ `document_uri` map to their DOM equivalents.
77
+
78
+ JavaScript properties that collide with Ruby methods can always be accessed with `[]`:
79
+
80
+ ```ruby
81
+ proxy["count"]
82
+ proxy["class"]
67
83
  ```
68
84
 
85
+ When a Ruby block is passed to a JavaScript method, it is appended as the final callback
86
+ argument. The callback receives its JavaScript `this` value first, followed by the native
87
+ callback arguments, all wrapped when necessary:
88
+
89
+ ```ruby
90
+ target.add_event_listener("click") do |receiver, event|
91
+ event.prevent_default
92
+ puts receiver
93
+ end
94
+ ```
95
+
96
+ `JS::Proxy` is enumerable for JavaScript iterable and array-like objects, including
97
+ arrays, `Set`, `Map`, `NodeList`, and objects with numeric indexes and a `length`.
98
+
69
99
  ## JQuery example
70
100
 
71
101
  ```ruby
data/lib/js/proxy.rb CHANGED
@@ -1,12 +1,20 @@
1
+ # backtick_javascript: true
2
+ # frozen_string_literal: true
3
+
1
4
  require "opal"
2
5
  require "native"
3
6
 
4
7
  module JS
8
+ # Shared conversion helpers for values crossing the Ruby/JavaScript boundary.
5
9
  module Helpers
6
10
  def wrap_result(result)
7
- if `result && typeof result.then === 'function'`
11
+ return nil if `result == null`
12
+
13
+ if `typeof result.then === "function" && !result.then.$$owner`
8
14
  Promise.new(result)
9
- elsif `typeof result === 'object' && result !== null`
15
+ elsif `result instanceof Number || result instanceof String || result instanceof Boolean`
16
+ `result.valueOf()`
17
+ elsif `typeof result === "object"`
10
18
  Proxy.new(result)
11
19
  else
12
20
  result
@@ -14,114 +22,151 @@ module JS
14
22
  end
15
23
 
16
24
  def native_methods
17
- @native_methods ||= %x{
18
- let obj = #{to_n};
19
- const props = new Set();
25
+ %x{
26
+ let object = #{to_n};
27
+ const properties = new Set();
28
+
29
+ while (object !== null) {
30
+ for (const key of Reflect.ownKeys(object)) {
31
+ if (typeof key === "symbol") continue;
20
32
 
21
- while (obj !== null) {
22
- for (const key of Reflect.ownKeys(obj)) {
23
- const stringKey = key.toString()
24
- const rubyName = #{to_rb_name(`stringKey`)}
33
+ const nativeName = key.toString();
34
+ const rubyName = #{to_rb_name(`nativeName`)};
25
35
 
26
- if (typeof key !== 'symbol' && stringKey !== rubyName ) { props.add(rubyName); }
27
- props.add(stringKey);
36
+ properties.add(nativeName);
37
+ properties.add(rubyName);
28
38
  }
29
- obj = Object.getPrototypeOf(obj);
39
+ object = Object.getPrototypeOf(object);
30
40
  }
31
41
 
32
- return Array.from(props);
42
+ return Array.from(properties);
33
43
  }
34
44
  end
45
+
46
+ private
47
+
48
+ def unwrap_result(result)
49
+ Native.try_convert(result, result)
50
+ end
35
51
  end
36
52
 
53
+ # Provides Ruby-style access to the properties and methods of a JavaScript object.
37
54
  class Proxy
38
55
  include Enumerable
39
56
  include Helpers
40
57
 
41
- attr_accessor :native
42
-
43
- IRREGULARS = %w(html url uri)
58
+ IRREGULARS = %w[html url uri].freeze
44
59
 
45
60
  def initialize(native)
46
- @native = Native(native)
61
+ self.native = native
47
62
  end
48
63
 
49
- def method_missing(name, *args, &block)
50
- js_name = to_js_name(name)
51
-
52
- unless existing_property?(js_name) || js_name.end_with?("=")
53
- raise NoMethodError, "undefined method `#{name}` for #{self}"
54
- end
64
+ def native
65
+ Native(to_n)
66
+ end
55
67
 
56
- if js_name.end_with?("=")
57
- prop = js_name[0..-2]
58
- native[prop] = args.first
59
- else
60
- val = native[js_name]
61
-
62
- if `typeof val === 'function'`
63
- js_args = args.dup
64
-
65
- if block
66
- js_callback = %x{
67
- function() {
68
- let args = Array.prototype.slice.call(arguments);
69
- return #{block.call(self.class.new(`this`), *args)};
70
- }
71
- }
72
- js_args << js_callback
73
- end
74
-
75
- result = `val.apply(#{to_n}, #{js_args.to_n})`
76
- wrap_result(result)
77
- elsif `typeof val === 'object' && val !== null`
78
- wrap_result(val)
79
- else
80
- val
81
- end
82
- end
68
+ def native=(value)
69
+ @native = Native.try_convert(value, value)
83
70
  end
84
71
 
85
- def to_str
86
- `#{to_n}.toString()`
72
+ def method_missing(name, *args, &block)
73
+ setter = name.end_with?("=")
74
+ property = resolve_property_name(name, allow_missing: setter)
75
+
76
+ return super unless property
77
+ return write_property(property, args.first) if setter
78
+
79
+ read_property(property, args, block)
87
80
  end
88
81
 
89
82
  def respond_to_missing?(name, include_private = false)
90
- true
83
+ setter = name.end_with?("=")
84
+ !!resolve_property_name(name, allow_missing: setter) || super
91
85
  end
92
86
 
93
- def each
94
- return enum_for(:each) unless respond_to?(:length)
87
+ def each(&block)
88
+ return enum_for(:each) unless block
95
89
 
96
- length = self.length
97
- (0...length).each do |i|
98
- yield self[i]
90
+ if iterable?
91
+ each_iterable(&block)
92
+ elsif array_like?
93
+ each_array_like(&block)
94
+ else
95
+ raise TypeError, "#{self.class} does not wrap an iterable or array-like object"
99
96
  end
97
+
98
+ self
100
99
  end
101
100
 
102
101
  def [](index)
103
- val = native[index]
104
- wrap_result(val)
102
+ wrap_result(`#{to_n}[#{index}]`)
105
103
  end
106
104
 
107
- def []=(k, v)
108
- native[k] = v
109
- wrap_result(native)
105
+ def []=(key, value)
106
+ converted = unwrap_result(value)
107
+ `#{to_n}[#{key}] = #{converted}`
108
+ value
110
109
  end
111
110
 
112
111
  def to_n
113
- native.to_n
112
+ @native
113
+ end
114
+
115
+ def to_str
116
+ `String(#{to_n})`
114
117
  end
115
118
 
116
119
  def length
117
- native.length
120
+ `#{to_n}.length`
118
121
  end
119
122
 
120
123
  private
121
124
 
122
- def to_js_name(name)
123
- name.to_s.split('_').map.with_index do |part, index|
124
- if IRREGULARS.include? part.gsub("=", "").downcase
125
+ def read_property(property, args, block)
126
+ value = `#{to_n}[#{property}]`
127
+ return wrap_result(value) unless `typeof value === "function"`
128
+
129
+ invoke_native_function(value, args, block)
130
+ end
131
+
132
+ def write_property(property, value)
133
+ converted = unwrap_result(value)
134
+ `#{to_n}[#{property}] = #{converted}`
135
+ value
136
+ end
137
+
138
+ def invoke_native_function(callable, args, block)
139
+ arguments = args.dup
140
+ arguments << callback_for(block) if block
141
+ wrap_result(`callable.apply(#{to_n}, #{arguments.to_n})`)
142
+ end
143
+
144
+ def callback_for(block)
145
+ %x{
146
+ return function() {
147
+ const callbackArgs = Array.prototype.slice.call(arguments).map(function(argument) {
148
+ return #{wrap_result(`argument`)};
149
+ });
150
+ const receiver = #{wrap_result(`this`)};
151
+ return #{unwrap_result(block.call(`receiver`, *`callbackArgs`))};
152
+ };
153
+ }
154
+ end
155
+
156
+ def resolve_property_name(name, allow_missing: false)
157
+ ruby_name = name.to_s.delete_suffix("=")
158
+ candidates = js_name_candidates(ruby_name)
159
+ candidates.find { |candidate| existing_property?(candidate) } ||
160
+ (candidates.last if allow_missing)
161
+ end
162
+
163
+ def js_name_candidates(name)
164
+ [name, camelize(name), camelize(name, acronyms: true)].uniq
165
+ end
166
+
167
+ def camelize(name, acronyms: false)
168
+ name.split("_").map.with_index do |part, index|
169
+ if acronyms && IRREGULARS.include?(part.downcase)
125
170
  part.upcase
126
171
  else
127
172
  index.zero? ? part : part.capitalize
@@ -132,51 +177,70 @@ module JS
132
177
  def to_rb_name(name)
133
178
  name
134
179
  .to_s
135
- .gsub(/([A-Z]+)/) { "_#{$1.downcase}" }
136
- .sub(/^_/, '')
180
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
181
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
182
+ .tr("-", "_")
183
+ .downcase
137
184
  end
138
185
 
139
186
  def existing_property?(property)
140
187
  `#{property} in #{to_n}`
141
188
  end
142
- end
143
189
 
144
- class Promise < Proxy
145
- include Helpers
190
+ def iterable?
191
+ `typeof Symbol !== "undefined" && typeof #{to_n}[Symbol.iterator] === "function"`
192
+ end
146
193
 
147
- def then(&block)
148
- js_callback = %x{
149
- function(value) {
150
- var ruby_result = #{block.call(wrap_result(`value`))};
151
- if (ruby_result && typeof ruby_result.then === 'function') {
152
- return ruby_result;
153
- } else if (ruby_result && typeof ruby_result.to_n === 'function') {
154
- return ruby_result.to_n();
155
- } else {
156
- return ruby_result;
157
- }
158
- }
194
+ def array_like?
195
+ %x{
196
+ const length = #{to_n}.length;
197
+ return typeof length === "number" &&
198
+ Number.isFinite(length) &&
199
+ length >= 0 &&
200
+ Math.floor(length) === length;
159
201
  }
202
+ end
203
+
204
+ def each_iterable
205
+ iterator = `#{to_n}[Symbol.iterator]()`
206
+
207
+ loop do
208
+ step = `iterator.next()`
209
+ break if `step.done`
160
210
 
161
- self.native = `#{to_n}.then(#{js_callback})`
211
+ yield wrap_result(`step.value`)
212
+ end
213
+ end
214
+
215
+ def each_array_like
216
+ (0...length).each { |index| yield self[index] }
217
+ end
218
+ end
219
+
220
+ # Ruby wrapper for JavaScript promises with chain-preserving return values.
221
+ class Promise < Proxy
222
+ def then(&block)
223
+ result = block ? `#{to_n}.then(#{promise_callback(block)})` : `#{to_n}.then()`
224
+ Promise.new(result)
162
225
  end
163
226
 
164
227
  def catch(&block)
165
- js_callback = %x{
166
- function(error) {
167
- var ruby_result = #{block.call(wrap_result(`error`))};
168
-
169
- if (ruby_result && typeof ruby_result.then === 'function') {
170
- return ruby_result;
171
- } else if (ruby_result && typeof ruby_result.to_n === 'function') {
172
- return ruby_result.to_n();
173
- } else {
174
- return ruby_result;
175
- }
176
- }
177
- }
228
+ result = block ? `#{to_n}.catch(#{promise_callback(block)})` : `#{to_n}.catch()`
229
+ Promise.new(result)
230
+ end
178
231
 
179
- self.native = `#{to_n}.catch(#{js_callback})`
232
+ private
233
+
234
+ def promise_callback(block)
235
+ %x{
236
+ const proxy = #{self};
237
+
238
+ return function(value) {
239
+ const wrappedValue = proxy.$wrap_result(value);
240
+ const rubyResult = block.$call(wrappedValue);
241
+ return proxy.$unwrap_result(rubyResult);
242
+ };
243
+ }
180
244
  end
181
245
  end
182
246
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module OpalProxy
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/opal_proxy.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- if RUBY_ENGINE == 'opal'
3
+ if RUBY_ENGINE == "opal"
4
4
  require_relative "js/proxy"
5
5
  else
6
6
  require "opal"
7
7
  require_relative "opal_proxy/version"
8
8
 
9
- Opal.append_path File.expand_path('lib', __dir__)
9
+ Opal.append_path File.expand_path("lib", __dir__)
10
10
  end
11
11
 
12
+ # Namespace for Opal Proxy versioning and Ruby-side integration.
12
13
  module OpalProxy
13
14
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: opal_proxy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joseph Schito
@@ -24,8 +24,7 @@ dependencies:
24
24
  - !ruby/object:Gem::Version
25
25
  version: 1.8.2
26
26
  description: Opal Proxy provides a dynamic interface to JavaScript objects in Opal,
27
- allowing seamless property access, method calls, and Promise handling using idiomatic
28
- Ruby syntax.
27
+ with idiomatic property access, method calls, and Promise handling.
29
28
  email:
30
29
  - joseph.schito@gmail.com
31
30
  executables: []
@@ -48,6 +47,7 @@ metadata:
48
47
  homepage_uri: https://github.com/josephschito/opal_proxy
49
48
  source_code_uri: https://github.com/josephschito/opal_proxy
50
49
  changelog_uri: https://github.com/josephschito/opal_proxy/blob/main/CHANGELOG.md
50
+ rubygems_mfa_required: 'true'
51
51
  rdoc_options: []
52
52
  require_paths:
53
53
  - lib
@@ -62,7 +62,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
62
  - !ruby/object:Gem::Version
63
63
  version: '0'
64
64
  requirements: []
65
- rubygems_version: 3.6.9
65
+ rubygems_version: 4.0.16
66
66
  specification_version: 4
67
67
  summary: Dynamic Ruby-style wrapper for JavaScript objects in Opal.
68
68
  test_files: []